Remove repeated Plant, Financial and Workforce shortcut cards. Add site-scoped Plant and Financial sidebar attention status with automatic refresh.
Include accumulated delivery processing, purchase order recovery, supplier reconciliation, plant data and user management changes and audit records.
Fresh duplicate check before posting, dismiss route sets stage, free accessory
lines no longer crash promotion, readiness treats only posted notes as terminal,
document-reference/usable-PO helpers, PO delivery reconciliation API + docs and
audit register.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Generalizes the Plant page's add-dialog into a shared AddVehicleButton
component parameterized by unit_type, and wires it into the vehicles
page against the existing POST /api/vehicles endpoint.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Tick path
- Bump STALE_CLAIM_MS from 10 to 30 minutes. A 29-source OCR batch
hit ~29 min and was being reclaimed mid-run by the 10-minute cap,
leaving the batch in inbox with last_error='reclaimed after a
stalled run' even though nothing was stalled. MAX_ATTEMPTS still
bounds a truly stuck batch.
- After each dn_batch_pages create, the staged runner bumps
dn_batches.page_count in the same lease-fenced batch. Final
assembly write at run-staged.ts:223 still sets the authoritative
value, so any drift from a reclaim-then-restart cycle is corrected.
Inbox tiles now show live progress instead of 0 pages for the
entire reading window.
PocketBase batch transport
- sendRecordBatch chunks across pb.createBatch() calls at the
server 50-request cap. A 71-candidate assembly sent as one
request was returning Invalid batch request data. and 500ing —
the assembly route caught it and surfaced as the generic Grouping
could not be saved. 73 mutations now split into 50 + 23 and land.
Assembly save UX
- The dismiss dialog now renders directly under the open-pages alert
instead of at the bottom of the section (which was hidden behind 71
document tiles on big batches). Clicking Dismiss page opens the
reason input where the user is looking.
- saveGrouping surfaces the server-side error code
(stale_revision / posted_candidate / dismissed_candidate /
assembly_in_progress / invalid_page_coverage) instead of a generic
Try again. On success, the page now redirects to /inbox so the
documents land in their columns.
- The Technical details disclosure is gone from the grouping screen.
Open pages already surface their reason in the alert above; the
duplicate disclosure just competed with the decision UI.
Assembly route correctness
- After a successful manual save the route now writes
stage=complete, status=needs_attention, notes_detected,
processing_completed_at and clears claimed_at/last_error. The
runner advances stage on its own runs but a manual save was
never followed by a runner pass — without this the batch stayed
in Check page grouping forever even though assembly was done.
- For existing candidates the route only updates grouping metadata
(pages, page_from, page_to, assembly_revision, status, stage,
checks). OCR-derived fields (doc_number, supplier_name_raw,
items, raw_text) are preserved on the row — the runner only
re-fills those for batches in status=inbox, and post-save the
batch moves to needs_attention and never reaches the runner
again. New candidates still get a createdCandidateFields row
with the OCR fields blank, ready for the runner or operator
entry.
- A Re-run batch button was added to the document detail header
so the operator can refill the wiped OCR data (or re-resolve
checks after a manual override) without first navigating to the
batch detail page.
Customer identity check
- A Skip this check toggle on the document detail page records
manual_overrides.skip_customer_identity. run-staged reads the
override and turns the customer_identity check into passed=null
(skip) instead of passed=false (fail), so OCR false-positives
on the customer VAT no longer block posting while still
requiring an explicit operator decision.
- The PATCH route accepts skip_customer_identity as an editable
field so the toggle saves through the standard note route.
Tests
- 180 files / 1767 tests pass; typecheck clean.
- Ask's supplier loader didn't pass date_received, so supplier_balances
and the supplier_balance dataset crashed
- supplier_balances takes a period (thisMonth, thisWeek, month1-3) and
returns what was received from each supplier in it (payable)
- PocketBase's space-separated dates dropped deliveries on the 1st from
period filters, including the Suppliers page's Payable KPI
- The PDF route falls back to the answer text when a saved report has
no sections (older format), instead of a 500
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Ask page sized itself as 100svh-4rem, but the Topbar is 89px on
desktop and 167px on phones, so the page overflowed and the bar scrolled
under the header. The Topbar now publishes its live height as
--topbar-h and the Ask page fills exactly the space below it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A turn can call the same tool more than once (e.g. two query_records),
which gave duplicate React keys and a console warning.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Audited /dashboard, /dashboard/sites (+ detail), /dashboard/workforce/workers,
/dashboard/workforce/on-site, /dashboard/plant/vehicles (+ detail),
/dashboard/plant/equipment (+ detail), /dashboard/plant/fuel-logs,
/dashboard/financial/purchase-orders (+ detail),
/dashboard/financial/delivery-notes/inbox, /dashboard/financial/suppliers,
and /dashboard/chat at 375x812 and 768x1024 using a scroll-right test to
catch real horizontal page overflow (as opposed to a table's own,
intentional internal scroll).
Two repeating bug patterns accounted for every overflow found:
1. A page-specific `right={<div className="flex items-center gap-2">...}`
passed to Topbar can carry two or three labelled buttons (edit/import/
merge/back etc.). Without `flex-wrap` they don't fit a 375px screen in
one row and push the whole page sideways. Fixed on the vehicles,
equipment, and site detail pages, plus their list-page counterparts
(vehicles fuel-view, equipment plant-view) — all five instances of
this exact pattern. Same fix applied to a delivery-note card's button
cluster on the PO detail page, and to Topbar's own right-side
container as a general backstop.
2. `Intl.NumberFormat("en-ZA", ...)` joins currency/number groups with
non-breaking spaces, so a wide value like "R 24 988 663" can't wrap.
In a 3-up grid (site cards' Contract/Spent/Remaining) that overflowed
the column; fixed by stacking those to 1-up below `sm`. In a 2-up grid
(PO detail KPI tiles) added `min-w-0` so the tile clips instead of
growing the grid track past the viewport.
Also: the shared Dialog now caps at `max-h-[90dvh]` with internal
scroll (a dialog taller than the phone viewport had no way to reach
its close button or footer before), and the sidebar hamburger button
was overlapping the Topbar's title on every page below `lg` — reserved
space for it instead.
Tables already scroll horizontally inside their own card via the
shared `<Table>` component or an `overflow-auto` wrapper, and KPI
grids already collapse responsively — both were fine everywhere checked.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Found live: rendering a report PDF 500'd with "ENOENT ... data.trie".
pdfmake's server PdfPrinter pulls in pdfkit -> @foliojs-fork/fontkit,
which loads font-shaping tables from a path relative to its own
installed location at runtime; webpack-bundled into the route's chunk,
that lookup can't find them. serverExternalPackages leaves the whole
chain on native Node `require`, where the real node_modules layout
resolves them — the same reason Next already ships `sharp` and
`@react-pdf/renderer` externalized by default.
Also found live: a bar chart with ~30 categories (fuel by vehicle)
printed completely overlapping x-axis labels. categoryLabelStride
thins them the way recharts' minTickGap does, keeping only as many
labels as the plot width can fit without collision.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The "Download PDF" button (both the report CTA and the per-answer icon)
and the once-per-report auto-download now fetch
GET /api/ask/messages/[id]/pdf instead of screenshotting the DOM with
html-to-image. That fetch/blob/anchor-download logic lives in its own
new download-pdf-button.tsx, kept small and separate from chat-view.tsx
since another change is landing in that file's sidebar at the same time.
A Turn's `id` (the stored ask_messages row id) now rides in from a
thread reload (rowToTurn) or from the stream's "done" event once the
row is persisted; both the button and the auto-download wait for it.
Removes answer-pdf.ts (the html-to-image renderer and its page-slicing
math) and its tests — nothing else used them. slugify moved to
src/lib/reports/pdf/filename.ts in an earlier commit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GET /api/ask/messages/[id]/pdf builds a PDF for one stored assistant
row: its structured make_pdf_report document when `parsed.pdf` is set,
otherwise its own markdown (headings/paragraphs/lists/GFM tables, with
[[chart:id]]/[[image:id]] tokens resolved against that message's own
stored charts and any referenced generated-image rows). 404s for a
missing message, a non-assistant row, or a thread owned by someone
else — same "not found, not forbidden" reasoning as the existing
thread routes, since there are no logins yet. Returns
application/pdf with Content-Disposition: attachment; filename="ask-<slug>-<date>.pdf".
The chat stream's "done" event now carries the persisted assistant
row's own id (messageId), which the browser needs to ask this route
for a PDF — see the next commit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
make_pdf_report no longer just tags an answer with a title/subtitle —
it now takes { title, subtitle?, sections: [{ heading?, text?, kpis?,
chartId?, imageId?, table? }] }, matching src/lib/reports/pdf/document.ts's
ReportDocument. A section's table prefers { resultId } (pulled from the
cached query_records result server-side) over { columns, rows }, so the
model still never hand-copies a figure. The document is stored as-is
(no rendering here — that's the next commit's route) and streamed to
the browser via the existing `pdf` SSE event.
Updates the system prompt's PDF rule to describe the structured call.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Replaces Ask's screenshot-then-wrap PDFs with an actual PDF pipeline
that runs in plain Node (no Chromium/Puppeteer, Alpine-safe): pdfmake
draws real text, real tables (zebra rows, right-aligned numbers, a
repeated header row across a page break) and vector charts (bar,
stacked bar, line, area, donut) built from ChartSpec as hand-rolled
SVG, using pdfkit's standard 14 fonts so no font files ship at all.
Chosen over @react-pdf/renderer for its native repeated-table-header
support, which that library only gets via manual pagination.
- chart-svg.ts: ChartSpec -> drawing plan (bar count, axis max) -> SVG.
- markdown-blocks.ts: GFM markdown -> headings/paragraphs/lists/tables.
- table-content.ts / kpi-content.ts / chart-content.ts / image-content.ts:
the pdfmake content nodes for each piece.
- chrome.ts: masthead + running header + "Page n of N" footer.
- resolve-section-table.ts: a report section's table, preferring the
cached query_records result (table: { resultId }) so figures are
never hand-copied.
- build.ts: assembles either an answer's markdown or a make_pdf_report
structured document into one PDF.
Verified against real pdftotext/pdftoppm output, including the header
repeating across a page break.
Adds pdfmake, unified, remark-parse (+ @types/pdfmake, @types/pdfkit);
drops html-to-image, whose only caller is removed in a later commit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Desktop gets a collapse/expand toggle in the chat header
(ThreadSidebarToggle) that persists to localStorage via
useThreadSidebarCollapsed, a useSyncExternalStore-backed hook — chosen
over a useEffect+setState "read on mount" hook because that pattern
trips react-hooks/set-state-in-effect, and the error budget for this
branch has no room to add one.
Phones already hid the panel behind a slide-over drawer; the missing
piece was a header trigger that shows the current thread's title
(ThreadDrawerTrigger), and Esc-to-close alongside the existing
backdrop-tap/pick-a-thread close paths.
Both new components are intentionally small and separate from the
main file, since the PDF download button in the same file is being
worked on concurrently by another change.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Topbar was already `sticky top-0`, which tracks the whole-page
scroll fine on both phones and desktop since neither the dashboard
layout nor any page wraps it in a clipping scroll container. What was
missing was the iOS PWA notch: add `paddingTop: env(safe-area-inset-top)`
so the bar's own background paints under the status bar/notch in
home-screen standalone mode instead of leaving a transparent gap.
z-30 stays below the z-40/z-50 used by drawers, dropdowns and dialogs,
and below the Leaflet map page's isolated stacking context (1760f77),
so the Topbar's dropdowns still float above the map and dialogs still
float above the Topbar.
Also fixes a real overlap bug surfaced while auditing at 375px: the
sidebar's fixed hamburger button sits at the same top-left corner as
the Topbar's title below `lg`, and nothing reserved room for it, so
the page title rendered partially underneath the button. Reserve that
space with padding instead, and truncate the title/subtitle so long
site names don't overflow the header.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MINIMAX_BASE_URL already ends in /v1, so appending /v1/image_generation
hit a non-existent path and every image request failed. Also accept the
image_urls response shape by downloading the image.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Ask-made PDF reports were shipping charts with axes but no bars/line:
html-to-image serializes the answer clone to a standalone SVG document
and rasterizes that, and never re-resolves CSS custom properties in
that step. Recharts sets fill/stroke straight to var(--series-1) etc.
as raw SVG attributes (report-chart.tsx's seriesColor), so an
unresolvable fill fell back to SVG's default (black — solid black
bars) and an unresolvable stroke fell back to "none" (an invisible
line), reproduced live with "Make me a PDF report on fuel at The Hills
for September".
bakeCssVariableColors walks the clone once it's attached to the
document with its light theme forced, and replaces every var(...)
fill/stroke attribute with its computed literal color before capture.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ask_threads/ask_messages/ask_log shipped with created/updated typed as
plain `date` fields, which PocketBase never populates — every row's
created has been empty since. pb-add-ask-collections.py now emits
autodate fields (onCreate, plus onUpdate for updated) and migrates the
three live collections in place under --apply, re-adding
idx_ask_messages_thread_created if PocketBase drops it with the field.
--backfill (apply-only) just reports affected row counts: PocketBase
can only set an autodate field to *now*, never a historical value, so
there's nothing safe to backfill.
Since old rows keep an empty created either way, the read side sorts
ask_messages by `created,id` instead of `created` alone, so tied
(all-empty) rows land in a stable, deterministic order rather than
whatever order PocketBase happens to return them in.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ask_log rows come back with created: "" — the collection's created/
updated fields are declared as plain date fields rather than PocketBase's
autodate type, so nothing populates them on create. That's a schema fix
(a human --apply step per Task 1's own rules, out of scope here). In the
meantime, the Change Requests page rendered new Date("").toLocaleDateString()
verbatim as the string "Invalid Date". Omit the line instead when the
date doesn't parse.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Follow-up to 7773ac8: those tests build a bare new Request(), which
(unlike a real HTTP request) has no Host header unless one is set
explicitly. isSameOrigin now checks Host, so all three routes' tests
started failing every non-cross-site case. Default a Host header
derived from the test's own ORIGIN, and set it explicitly (mismatched
against Origin) in the cross-site case so that test still exercises a
real mismatch rather than a merely-missing header.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Live-testing the PDF report ("fuel at The Hills for September") showed
the same chart block rendered twice: once at the bottom of page 1,
again at the top of page 2. Every page drew the *entire* rendered
answer image, repositioned so its own break offset lined up with the
page's top — relying on the page's fixed height to crop away
everything else. That only works when a page's content run happens to
be exactly CONTENT_HEIGHT_PX tall; slicePages deliberately breaks
*earlier* to avoid cutting a block in half, so the leftover space at
the bottom of the shorter page bled into the next block's content
instead of staying blank.
Now each page gets its own canvas-cropped PNG sized to exactly the
content it should show (breaks[i] to breaks[i+1]), embedded as a
separate image — no reliance on page bounds to "crop".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Confirm/Reject/Undo were unusable from any host that didn't happen to
match Next's internally-reported request.url hostname: dev serves on
127.0.0.1 (or a LAN IP) but request.url came back normalised to
"localhost", so every genuine same-origin click failed with "Cross-
site request refused" (403) even though the browser's real Origin
header was correct. Compare Origin's host against the request's own
Host header instead, which always reflects what the client actually
sent.
Found live-testing the fixed-cost Confirm/Undo flow: PocketBase showed
the row was never actually blocked from being created by the guard
itself, but every apply/reject/undo click from the browser 403'd.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Live-testing a free-query chart ("litres per vehicle per week") turned
up two bugs:
- query_records grouped rows in first-seen order, not calendar order.
Grouping by (vehicle, week) walks one vehicle's weeks at a time, so a
week bucket could appear more than once and out of order — and a
chart reads that row order straight onto its x-axis. Bucketed
results now always sort chronologically by the bucket field first;
anything the model sorts by still applies as a tie-breaker.
- make_chart accepted a non-numeric column (e.g. the "vehicle" label
column) as a series, silently producing a blank plot with no y-axis
since its cell values aren't numbers. It now checks actual cell
values and returns a clear error naming the numeric columns
available, so the model can correct itself instead of shipping an
empty chart.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A failed tool call only ever reached the model as a short "Error: ..."
message so it could recover — but nothing was logged server-side, so
a real bug (as opposed to the model misusing a tool) left no trace to
debug from. Log the full error to the server console before handing
the short message back.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>