TLB06 was paid off in October 2026, but the running-cost rule dropped a SETTLED
agreement from EVERY month, so the ten instalments it was really paying
(January-October, R290 360,80) vanished from the year to date.
An agreement is now charged for the months it was live, and its paid-off date
decides when the money stopped:
- LIVE: from its start through its paid-off date, or through the current month
when no paid-off date is on record.
- SETTLED: up to its paid-off date - those instalments really were paid - and
nothing at all when there is no paid-off date, because there is then no month
we can say it paid for. "Settled" means paid off, or will be paid off by
that date, so a settled agreement whose date is still ahead is still charged.
TLB06 is charged January-October and NOT November. Nothing else moves: every
other agreement's window already ran to November or beyond.
- Overview YTD R6 241 894,70 -> R6 532 255,50. The November figure is
unchanged at R441 493,12.
- Financing register YTD instalments R4 395 038,56 -> R4 685 399,36, and
"Committed for November" stays R399 548,96.
The Overview and the register still read ONE rule, so their finance year to
date is the same figure - asserted in lib/running-month.test.ts, together with
the TLB06 case and the live-agreement cases.
committedMonthly() in lib/financing.ts was never called and stated the old
rule, so it is gone rather than left lying next to the real one.
The client asked for two things on the running-cost pages: drop the two prose
notes, and show the year-to-date figure next to the total for the current month.
- /running loses the "Shown to Jan, Feb..." and "A re-cut of costs..."
paragraphs. The tiles carry the year and the month instead.
- /running shows "Nov so far" beside "YTD running cost", and the "made of"
line is printed for the month as well as for the year, so the month's total
is explained on the page and not only in the grid.
- The cards follow the income cards: YTD spend | Nov so far side by side from
md, the month on its own line below md, and the sparkline yields its width
below xl so the vehicle NAME is never truncated to pay for a decorative
trend. Below sm the name wraps instead of being cut short.
- /running/insurance shows the premium in force this month, and its
year-to-date figure now stops at the months that have happened. December is
a rate on file, not money spent, so including it made this page a full year
while the Overview showed eleven months - the same cost kind reading as two
different numbers on two screens.
- /running/financing shows "Committed for Nov" beside a new year-to-date
instalment figure.
One rule, three screens. elapsedMonths() in lib/income-month.ts counts the
months that have happened and is the same clock the income pages use, and
financeByAsset() / fleetFinanceByMonth() in lib/running-costs.ts are the only
finance rule the Overview and the financing register both read. The Overview's
insurance and finance year-to-date figures now equal the insurance and
financing pages' figures exactly.
lib/running-month.test.ts asserts it, including the insurance cross-check
between the register and the Overview.
A settled agreement still contributes nothing to any month - the instalments
TLB06 was paying in January-October are not in the Overview's finance figure.
The client confirmed the financing is correct as it stands, so that is left
alone and recorded in AGENTS.md rather than quietly changed.
The four kinds under running cost - repairs & parts, purchases, insurance
and finance - ARE the whole of a vehicle's running cost. That was true of
the arithmetic but not of the screen: a card showed one number with no
visible parts, and the only breakdown was a hover tooltip on the grid's
total row.
- Each card now shows its four parts, so what the vehicle's spend is made
of is visible without expanding it. A vehicle with no spend shows
nothing: "nothing recorded" is already on the card.
- The expanded panel shows the four parts against the vehicle's total.
- The grid's footer shows the fleet's four parts above the fleet total, so
a month column adds up on the screen instead of on trust.
- The page header states the fleet's four parts and the total side by side.
The parts ARE the spend and there is no fifth source, so the parts and the
total can never disagree. Any change that touches a cost kind must keep
that additivity true.
Also: the labels on this page were text-[10px], under the 12px floor the
mobile checklist sets. They are now 12px.
Two rulings recorded in AGENTS.md:
- FUEL IS NOT A PLANT RUNNING COST. The sites are invoiced for the fuel
they use. The only fuel that is a plant cost is the fuel a plant vehicle
takes from the tank for plant work, so a fuel log is never a running-cost
source however many litres it records.
- Only what is under running cost as it stands. The workbook's per-vehicle
fixed cost also carries depreciation/ownership and an operator
allocation; that is capital recovery and overhead, not running cost, so
it stays out - and the vehicles it covers (the excavator, the site and
office containers, the generators, the trailers) correctly show no
running cost on the Overview.
Two changes to the income overview, sharing one rule for what "the month"
means (lib/income-month.ts).
POSTED INCOME PER SITE
/income gains Vehicles / Sites views, and /sites defaults to the SAME
posted-income view with a separate Internal billed invoices view.
buildSiteIncomeRows (lib/site-income.ts) is the shared calculation: actual
monthly_income supplies the amount, year and month, and the linked invoice
supplies its historical site. It never uses assets.site, fuel-log
locations or today's map assignment.
- Unposted invoice lines are billing, not posted income.
- Historical workbook income with no site evidence stays in Unallocated
historical income; external income, whose site field names the SUPPLIER,
stays in Unallocated external income.
- Missing source references stay visible as unallocated invoice income.
No group is dropped to make a site list look cleaner and no site is
guessed to force a total.
- No site profit is calculated without an explicit cost-allocation rule.
- Invoice writes use revalidateInvoicePages, which invalidates both
registers, Income, Sites/details, Charts and Dashboard.
THE LIVE MONTH
The tiles show the year-to-date income and profit AND the current month,
and every vehicle card shows the current month income beside its year
figure.
- The month is the live CALENDAR month and it is shown even at R0. Income
for a month is usually entered near the end of it, so the current month
is a figure that starts at R0 and grows.
- The month index is computed on the SERVER and passed down as a number.
The browser never works it out for itself: two clocks disagreeing would
show two different months on the same page.
- A month profit is a dash until that month costs are posted. September
and October carry invoice income with no posted fixed costs (their costs
exist only as forecast rows), so a profit there would be income minus
zero - a 100% margin that does not tie back to the year-to-date profit.
It becomes a number by itself the moment the workbook posts that month.
- The 12-month strip shows the live month as R0 rather than a dash, so a
card cannot contradict itself. Months after it stay a dash.
- The sparkline on a collapsed card is hidden below xl: the content column
is only ~700px wide between md and xl, and the vehicle name must not be
truncated to pay for a decorative trend.
An invoice saved with no amount landed as amount_incl = 0 (PocketBase
stores a number field as zero, not null), so the PO read "R 0,00 invoiced"
and the money never booked. There were two ways to give it an amount and
neither worked: the Upload-invoice modal let a blank amount save silently,
while the double-click correction form refused to save without a value AND
a written reason, and sat behind Edit mode.
- lib/purchase-invoice-amount.ts holds the rules, shared by the screen and
the API so they cannot disagree. R0 is not an amount: a blank amount and
a figure of zero are both refused, so an invoice can never land as zero.
- The API now separates PRICING an invoice that never had an amount (no
reason owed, still logged) from CORRECTING one that was booked (Full user
+ a reason, as before).
- Double-clicking an amount on a PO opens the ordinary invoice modal on
that invoice, with the amount field waiting. The inline correction form is
gone — one window, one rule. There is no pencil button: a booked figure
must take a deliberate second gesture, so a single stray tap never opens
a form that can rewrite money.
- The PO-lines table freezes its Description column with an opaque
background and fades its right edge, so it passes the mobile checklist.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- /expenses/suppliers: terms (cash / 30 / 60 days), PO + petty cash totals per supplier,
period filters, add/edit/delete, due dates
- POs and slips resolve to a supplier by code then folded name; new PO suppliers start
with terms unset, new slip suppliers as cash
- Full-user guard on cash-float and job-card write routes
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
- Plant Clock "Job cards" tab: scan a vehicle QR to start a job, scan again to finish
with odometer (or no meter), kind of work and a note
- /running/job-cards table with period, work, vehicle and mechanic filters
- Remove /running/operators and /running/mechanics; mechanic badge PDF moves to Employees
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Insurance and finance genuinely recur, so left to fill all twelve months they made
November and December look known — ~R470k a month of phantom figures for months that
have not started, which is the "it will confuse the people" case exactly.
Both halves of the fix are needed:
- machineRunningCosts() caps every source at the horizon, so no phantom figure is even
computed. YTD is therefore genuinely year-to-date and moves as the year goes on.
- RunningView renders only that many columns. Future months are NOT rendered empty:
an empty column still reads as "we have a figure of zero", which is the same
confusion in a quieter form.
October is shown and is expected to grow as repairs and purchases land against a
vehicle. A PO dated beyond the horizon is skipped silently, which is not the same as
an undated PO — only a genuinely unreadable date is reported.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A machine with no repairs and no purchase order still carries an insurance premium
and, while it is financed, a monthly instalment. So a blank row was never "this
vehicle costs nothing" — it was "nothing recorded", which is a gap rather than a
figure. The Overview now totals all four kinds per machine per month.
Everything is read from POCKETBASE, the registers the client maintains in the app.
The original spreadsheet only bootstrapped the page (monthly_running_costs, Jan-Aug);
it is not a source here. September having no workbook data is expected and correct —
the figures there now come from the app.
Two finance sources existed and OVERLAPPED. The cost_lines instalment rows are the old
workbook's subset (5 machines, R115 576,14/mo); financing_terms is the client's current
sheet (18 agreements, R428 585,04/mo). Using both double-counted MG02, RV04, TLB06 and
TLB07, so financing_terms wins and the stale rows are ignored. TB01 has no financing_terms
row, so its old instalment no longer counts — consistent with it being absent from the
current sheet. Insurance has one source and no such problem.
Insurance is resolved from the RATE PERIODS rather than the derived cost_lines, so a
premium that moves mid-year is charged at the price that applied that month.
Also: settled agreements contribute nothing (a paid-off machine is not a running cost),
purchase lines are filed under the PO date, and the bottom row keeps showing incl above
excl. The page carries one line saying this is a re-cut of costs already inside fixed
expenses — adding it to them would count the same premium twice.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A purchase order buys parts and repairs against a plant item, so its money is
running cost and belongs in the same per-machine, per-month series the Overview
already reads. Without it September showed nothing at all.
Merged at READ time, never copied into monthly_running_costs: that collection is
deleted and rebuilt by every seed, so a purchase written there would vanish on the
next one and have to be re-uploaded. purchase_lines is never deleted, so the money
lives in one place and the Overview adds it up.
- Month comes from the PO date (when it was ordered), not "Required By":
MEGPPO50532 is dated 07/10 but required 29/08 and those must not be confused.
- Only lines allocated to a machine are counted. An unallocated line is real money
not yet on a plant item; it stays on /purchases, reported, rather than being
hidden or added to the fleet total.
- A PO with no readable date is excluded and named, never guessed into January.
- Bottom total row shows incl ABOVE excl per month and for the YTD. Workbook
figures carry no VAT column, so RUNNING_VAT_PCT (15, confirmed by every PO line)
is the single constant to revisit if they are ever restated ex-VAT.
September now reads R 5 842 incl / R 5 080 excl from the two September POs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Splits the Running Costs group into six purpose-built pages, each with its own
collection and its own source data, plus a coordinate parser for purchase orders.
Insurance (R503 330/yr, 20 vehicles)
A premium can move every month or three, so it is stored as a RATE PERIOD in
insurance_rates and each month's cost is resolved against the rate in force
that month. cost_lines insurance rows are derived from those periods. An absent
rate is null, never 0 — nobody has entered the date, which is not the same as
being insured for free.
Financing (20 agreements, R428 585/mo)
Instalment from the sheet; start date, paid-off date and the settled toggle are
human-entered and preserved across re-seeds. Months-left and outstanding are
null until a date is entered, never R0 on a live debt. The two paid-off
agreements (WB02, DT01) show as settled and are excluded from the run-rate.
Purchases + Oil Stock
Uploads PO PDFs the way /income/invoices does. The parser reads columns by
x-position, because plain text extraction reorders them, and refuses any PO
whose lines do not sum to the sub-total. A cost code either names a machine
("SS-02" -> SS02) or a stock store ("MPLANT, ENGOIL") — never both, never
guessed. Oil POs move litres into oil_stock_movements instead of onto a machine;
stock is the sum of movements so the first usage is a subtraction from history.
Operators (19) and Mechanics (2)
Both are people with an employee number, separately reconcilable by the seed so
someone added in the app survives. Mechanics get a badge QR; a mechanic with no
employee number cannot be printed, because the code is what the QR encodes.
Cross-cutting
- The seed no longer deletes operators, mechanics or oil items: people are added
in the app, and those rows exist in no file.
- Re-pointing allocations that PocketBase clears because `assets` is rebuilt
with new ids each seed (operators, purchase lines).
- One QR encoder for screen and PDF, so a printed code cannot drift from the one
on screen. pdf-lib writes real vector PDFs; pdfjs-dist only reads them.
- Every figure is null-safe: unknown is never rendered as zero.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The name on an invoice means two different things. On an internal invoice it is a
SITE, transcribed from the invoice's own description. On an external invoice it is
the SUPPLIER - and the PDF does not print the supplier's company name at all, so
there is nothing to transcribe and a human has to type it.
`invoices.site` stays the single field; the column header follows the page (Site on
Internal, Supplier on External) and only the external one is editable, via
components/SupplierCell.tsx and PATCH /api/invoices/:id/site. It saves on blur or
Enter, never per keystroke, and an empty value clears the name so a stray
description fragment can be removed rather than displayed as a supplier.
Sites stop taking their names from external invoices, in both places:
- syncSitesFromInvoices only creates sites from internal invoices, so a job
description can never be registered as a yard.
- buildSiteSummaries only counts internal invoice lines towards a site, which is
what /sites and the dashboard's per-site chart read.
This is the "Move containers" case. PHI 192's description is "Crane Truck Hire 17
September 2026 Move containers", which had registered a site literally called
"Move containers" - a job, not a yard - on /sites, on the dashboard's per-site
chart and in the plant register's site filter. The record stays in the collection
(sites are never deleted) but is no longer surfaced; the count went 13 -> 12.
External income is not lost by this - it is booked to a machine and still appears on
/income and /charts. It is simply not a site.
The intake route stops resolving an external invoice's text against the site list,
which would have invented a site out of a description, and reports needsSupplier so
the import toast says the name is outstanding.
npm run seed now captures existing invoice names before it wipes the collection and
re-applies them where data/source does not specify one - the same data-loss trap the
uploaded PDFs already had. A JSON name always wins, so internal sites are unchanged.
Verified: typecheck clean, 45 tests pass (4 new, covering the "Move containers" case
directly), production build green, the new PATCH route 401s when anonymous, and
/sites plus both invoice pages pass the mobile checklist at 375x812 and 768x1024. The
supplier edit was exercised end to end and the test value reverted.
Still open: the three external invoices (PHI 178/186/192) have no supplier name yet
and none can be derived from the data. They show "Set supplier" until the client
supplies them.
Note: lib/rows.ts, lib/server-data.ts and the running/insurance feature are
someone else's in-flight work and were deliberately left uncommitted here.
Renames the existing /income/invoices page to "Internal Invoices" and adds a
sibling /income/external-invoices page, splitting ONE `invoices` collection by
the debtor printed on the invoice: a Mega group company is internal, anything
else is external.
Never two collections. An invoice must not be able to exist on neither page, and
the halves must stay reconcilable against /income. Both routes render through the
single components/InvoiceRegisterPage.tsx so they cannot drift apart.
lib/invoice-party-policy.json holds the group entity list and lib/invoice-party.ts
does the matching. Matching is on debtor name OR debtor_no, never name alone:
all three external invoices (PHI 178/186/192) carry an empty debtor name and only
a third-party number, so a name-only match would misfile every one of them. An
invoice naming neither is never internal - it lands on External and is reported
as unclassified rather than silently filed.
The split is 16 internal / 3 external across the 19 invoices in the register.
Also fixes three things the split would otherwise break:
- Booking a line, importing a PDF and retrying an intake can all land on either
side depending on the debtor, so the five write paths only revalidated
/income/invoices and would have left the other page stale. They now call
revalidateInvoicePages() from lib/invoice-revalidate.ts. Route paths live in
lib/invoice-routes.ts, which imports no next/* so a client component may import
it; the revalidate helper imports next/cache and is server-only.
- A site's "Go and assign them" link hard-coded the internal path, so a site whose
outstanding lines sit on external invoices would dead-end. SiteSummary now
carries the outstanding lines split by party.
- verify-auth-routes.mjs now covers the new route.
The two routes are siblings by design: isGroupActive matches a child by path
prefix, so a nested /income/invoices/external would highlight both leaves at once.
Verified: typecheck clean, 41 tests pass (7 new), production build green,
anonymous access to both routes 307s to /login, and the mobile checklist passes at
375x812 and 768x1024 - no horizontal page scroll, no tap target under 44px, no
text under 12px. The nested line-item table still scrolls inside its own container
without stretching the outer table.
Note: lib/rows.ts, lib/server-data.ts and the running/insurance feature are
someone else's in-flight work and were deliberately left uncommitted here.
Selecting a plant item in the Booked to dropdown silently did nothing. The menu
opened, the option highlighted, and clicking it produced no request at all — the
menu just closed. Reproduced with a real browser click: no POST to /book.
The menu is rendered with createPortal(..., document.body) because the line table
lives in an overflow-x-auto container that would clip an in-place dropdown. A
portal is not a DOM descendant of the component that made it, so the click-away
handler's `boxRef.contains(target)` reported every option as an outside click:
mousedown closed the menu, React unmounted the button, and the click that would
have booked it never landed. The menu now has its own ref and is treated as part
of the control. Verified with a real click — the booking posts, the site clears,
and the unbooked badge goes.
The site dropdown was never affected; it is a native <select>, outside the portal.
Also on this screen:
- The Booked to column needs min-w-[240px] with the two pickers stacked inside
it. At 768px the column is only ~166px, so two 200px controls could not sit
side by side; as flex children they compressed into each other by ~12px, and
once they were not allowed to shrink they spilled past the edge of the table.
- Dropped the "Booked to income / VAT to SARS / Billed to customer / Still
unbooked" money block, its component and the variables feeding it. The status
line stays: "1 line not yet booked to a machine".
Typecheck and build green. Checked at 768x1024 and 375x812: no page overflow, no
control past the table edge, no tap target under 44px.
On /income/invoices at tablet width the expanded invoice detail was unreadable
and the booking control was effectively unreachable.
- The line-item table is rendered inside a <td> of the invoice list's own
scrollable table, so its 1200px width stretched the OUTER table to 1234px and
the whole list scrolled sideways whenever a row was open. The detail is now
measured with a ResizeObserver and given that pixel width, so it can no longer
influence the outer table.
- Both tables' first columns are sticky left-0, and two nested stickies at the
same offset stack on top of one another: the invoice number, the site and the
period printed over each other. The detail wrapper is now sticky left-0 z-20,
above the outer cell's z-10, so the inner table scrolls in its own box.
- The unbooked row tint was bg-[#D1495B]/[0.04] — 96% transparent — so every
column a sticky cell covered bled through it on scroll. The tint is now the
solid #FDF3F4 (dark #292934), repeated exactly on the sticky cells.
- The KPI row was flex-wrap, which sizes tiles by CONTENT, so the wide "booked
to income" tile pushed the third one onto a second row below ~644px. It is a
grid-cols-3 now, with the figures sized to the column.
- The machine picker and the site dropdown in the Booked to cell were flex
children and compressed into each other by ~12px instead of sitting side by
side. Both are shrink-0 now.
- Booking a line always saved on selection with no Save button, which read as
"nothing happened". Unmatched rows now say so.
The booking API was already correct: a machine booking clears the site and posts
income, a site booking posts none. Verified at 768x1024 and 375x812.
Three things in here cost real time to work out, so they are written down
rather than left to be re-derived:
- The Recharts constraints behind the profit line (a Line is one <Curve>,
so <Cell> cannot recolour a segment; a Line with its own `data` corrupts
the shared categorical axis; profit Lines must be direct children or they
vanish silently; the tooltip needs custom `content`).
- globals.css hard-codes the axis tick font size, which beats Recharts'
tick.fontSize prop — so the 12px mobile bump has to happen in that CSS
rule or nowhere.
- /sites now defaults to Cards and must be kept in step with IncomeView.
Plus the two deliberate palette exceptions (--color-chart-loss,
--color-chart-profit) and the client rulings behind them.
Two things this question turned up.
The upload DID store the PDF, but on the intake record, never on the invoice
— so "Open invoice PDF" 404'd for every invoice imported through the app
while working for the older ones. The file now lives on invoices.source_pdf
and the intake record keeps only the provenance (who sent it, what was
parsed), rather than a second copy of the file. The document endpoint also
falls back to the intake record, which makes the seven already-stored
documents reachable again without a migration. All 10 now open.
The machine combobox searched only the register's own words, so an invoice
line reading "CB22 2.2 TON Roller Vibration" could never be matched to the
asset filed as "CAT 2,4TON RV" — the words simply were not in the list. The
line's own invoice code and description are now searchable too, so the two
sides of the same machine can actually be found from either direction.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds an Invoices sub-menu under /income with a summary table (one row per
invoice, click to expand the full detail) and the data model behind it.
Data:
- invoices + invoice_lines PocketBase collections, created idempotently by
scripts/setup-invoice-collections.mjs
- line items transcribed from PHI 166/167/171 with per-invoice arithmetic
validation in the seed; a bad invoice is refused without blocking the rest
- invoice-*.json in data/source are auto-imported, so adding an invoice is
one file
Booking:
- manual book-in per line (Booked to), grouped by category
- POST /api/invoice-lines/:id/book moves the income row rather than adding a
second one, so a booking can be changed freely
- scripts/book-invoice.mjs is idempotent: rows are stamped with
monthly_income.invoice_line and replaced on re-run
- booked amount follows lib/invoice-booking-policy.json (bookInclusiveVat),
read by both the app and the CLI so they cannot disagree
Money:
- the R24,205.13 column on these invoices is PLUS VAT, not escalation. The
seed now rejects VAT that disagrees with the stated rate.
- booked amounts are VAT-inclusive per the client ruling of 2026-10-01; the
VAT stays visible rather than hidden.
Uploads:
- drag-and-drop PDF attached to invoices.source_pdf
- GET /api/invoices/:id/document proxies it — the collection is rule-locked,
so a direct PocketBase /api/files link 404s in a browser
Fixes a month-aggregation bug this introduced: buildIncomeRows assigned into
months[i], so a machine with two income rows in one month (one per invoice,
which is normal multi-site use) showed only the last. SS01 September read
R20,700 instead of R35,305 while its own YTD included both. Months now
accumulate, per-month profit is computed once after accumulation, and
lib/rows.test.ts pins it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Part A — Round 3 regressions:
- A.1: verify-cost-reconciliation.mjs now loads .env.local, wraps in try/catch, exits 1 on any failure
- A.2: Vehicle Costs restored Santam — back to R 14,996.31 (matches row-by-row sum)
- A.3: incl<excl / VAT math / VAT>20% now FATAL — seed exits 2 with full report (4 anomalies on Charl-CCS + Buildsmart)
- A.4: Reconciliation target R 694,962.93 with both deltas itemised (Personnel +R19,800, Santam -R5,322.44, unexplained R 0.00)
- A.5: 'Lines' view uses actual lines (recharts LineChart), no fills; fills only in 'Cost stack'
Part B — Mobile standard:
- B.1: header wraps to 2 rows on mobile; YTD badge cards stack; page-wide overflow eliminated on /income, /charts, /overheads, /running, /expenses
- B.2: IncomeView card row layout stacked — name gets its own line on mobile, never truncated to single char
- B.3: Plant type filter is a <select> dropdown below lg (was 18 pills wrapping to 13 rows); same for sort on /income and /running
- B.4: Plant table first column sticky; -webkit-overflow-scrolling: touch; tables scroll inside their own container
- B.5: All buttons/links use min-h-[44px] tap targets; text below 12px is bumped where it matters
- B.6: Charts legend wraps below title; chart min-height h-44 → h-52 on mobile
- B.7: paddingTop env(safe-area-inset-top) on sticky header for notch/home indicator clearance
AGENTS.md created with the standing mobile checklist rule.