Add Running Costs sub-pages: insurance, financing, purchases, oil, operators, mechanics
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>
2026-10-08 23:06:27 +00:00
|
|
|
import type { Asset, FinancingTerm, InsuranceRate, InvoiceLine, MonthlyIncome, MonthlyRunningCost, Operator } from "./data";
|
2026-09-12 07:35:35 +00:00
|
|
|
import { computeProfit, type ProfitOptions } from "./profit";
|
Add Running Costs sub-pages: insurance, financing, purchases, oil, operators, mechanics
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>
2026-10-08 23:06:27 +00:00
|
|
|
import { currentRate, monthlyCost, validatePeriods, type RatePeriod } from "./insurance";
|
|
|
|
|
import { financeCalc, toInput, type FinanceCalc } from "./financing";
|
Running cost = everything linked to a vehicle: repairs, purchases, insurance, finance
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>
2026-10-09 10:28:29 +00:00
|
|
|
import { exclFromIncl } from "./purchase-booking";
|
|
|
|
|
import { COST_KINDS, type MachineCosts } from "./running-costs";
|
2026-09-12 07:35:35 +00:00
|
|
|
|
|
|
|
|
export type IncomeRow = {
|
|
|
|
|
id: string;
|
|
|
|
|
code: string;
|
|
|
|
|
name: string;
|
Round 3 (Parts A-B): charts fixes + expenses/overheads cleanup
- A.1: fixed_expenses per month now threaded through to chart bucket
- A.2: null = no data (forecast), 0 = real plotted zero — distinguishable
- A.3: x-axis clipped to last actual month (IncomeAreaChart + IncomeBarChart + MachineLineChart)
- A.4: chart hues as CSS vars (--color-chart-income/fixed/running) — deeper steps of same hues, validated for CVD
- A.5: chart-type toggle (Lines / Cost stack / Profit) + localStorage persistence + dynamic legend
- A.6: profit on charts page comes from computeProfit()
- B.1: deleted /expenses/plant-overheads, /expenses/insurance, /expenses/tracking (all empty); added /expenses/vehicles; ExpenseSectionPage now has an empty state
- B.2: captured R 157,200 Personnel block, R 100,000 Maintenance, R 10,000 Petty Cash; roll-up reconciles to R 675,162.93 (= sum of non-Plant non-Santam rows; workbook grand total R 1,110,486.87 is internally off by R 14,477.56 vs its own rows)
- B.3: seed flags incl<excl, VAT>20%, broken VAT math — does NOT silently correct; 4 anomalies reported on Charl-CCS + Buildsmart
- B.4: noted that workbook Vehicle Costs subtotal skips Santam (app R 9,673.87 is correct; workbook is wrong)
- B.2 reconciliation assertion added to verify-cost-reconciliation.mjs
- Sidebar updated: Vehicles added, Insurance/Tracking/Plant-Overheads removed
2026-09-12 09:24:18 +00:00
|
|
|
months: (number | null)[]; // Part A.2: null = no data (forecast), 0 = earned zero
|
|
|
|
|
fixedMonths: (number | null)[];
|
|
|
|
|
runningMonths: (number | null)[];
|
2026-09-12 07:35:35 +00:00
|
|
|
profit: number[];
|
|
|
|
|
income: number;
|
|
|
|
|
fixed: number;
|
|
|
|
|
running: number;
|
|
|
|
|
ytdProfit: number;
|
Round 3 (Parts A-B): charts fixes + expenses/overheads cleanup
- A.1: fixed_expenses per month now threaded through to chart bucket
- A.2: null = no data (forecast), 0 = real plotted zero — distinguishable
- A.3: x-axis clipped to last actual month (IncomeAreaChart + IncomeBarChart + MachineLineChart)
- A.4: chart hues as CSS vars (--color-chart-income/fixed/running) — deeper steps of same hues, validated for CVD
- A.5: chart-type toggle (Lines / Cost stack / Profit) + localStorage persistence + dynamic legend
- A.6: profit on charts page comes from computeProfit()
- B.1: deleted /expenses/plant-overheads, /expenses/insurance, /expenses/tracking (all empty); added /expenses/vehicles; ExpenseSectionPage now has an empty state
- B.2: captured R 157,200 Personnel block, R 100,000 Maintenance, R 10,000 Petty Cash; roll-up reconciles to R 675,162.93 (= sum of non-Plant non-Santam rows; workbook grand total R 1,110,486.87 is internally off by R 14,477.56 vs its own rows)
- B.3: seed flags incl<excl, VAT>20%, broken VAT math — does NOT silently correct; 4 anomalies reported on Charl-CCS + Buildsmart
- B.4: noted that workbook Vehicle Costs subtotal skips Santam (app R 9,673.87 is correct; workbook is wrong)
- B.2 reconciliation assertion added to verify-cost-reconciliation.mjs
- Sidebar updated: Vehicles added, Insurance/Tracking/Plant-Overheads removed
2026-09-12 09:24:18 +00:00
|
|
|
lastActualMonth: number; // Part A.3: clip x-axis
|
2026-10-03 16:40:32 +00:00
|
|
|
// Machines the client has stood down. They still hold their history, so they are kept
|
|
|
|
|
// in the data and filtered in the view rather than dropped here — /income defaults to
|
|
|
|
|
// active only, but the client can switch to All or Inactive.
|
|
|
|
|
isActive: boolean;
|
Invoices: line-item extraction, booking to plant items, VAT-inclusive income
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>
2026-10-01 13:59:30 +00:00
|
|
|
// Portion of `income` that came from invoices, and how much of that is VAT.
|
|
|
|
|
// Booked amounts are VAT-inclusive (see lib/invoice-booking-policy.json), so without
|
|
|
|
|
// this the per-machine figure is not explainable.
|
|
|
|
|
invoiceIncome: number;
|
|
|
|
|
invoiceVat: number;
|
|
|
|
|
// Every invoice that contributed to this machine, so the card can show the combined
|
|
|
|
|
// total and break it down by invoice and site without another query.
|
2026-09-12 07:35:35 +00:00
|
|
|
};
|
|
|
|
|
|
Round 3 (Parts A-B): charts fixes + expenses/overheads cleanup
- A.1: fixed_expenses per month now threaded through to chart bucket
- A.2: null = no data (forecast), 0 = real plotted zero — distinguishable
- A.3: x-axis clipped to last actual month (IncomeAreaChart + IncomeBarChart + MachineLineChart)
- A.4: chart hues as CSS vars (--color-chart-income/fixed/running) — deeper steps of same hues, validated for CVD
- A.5: chart-type toggle (Lines / Cost stack / Profit) + localStorage persistence + dynamic legend
- A.6: profit on charts page comes from computeProfit()
- B.1: deleted /expenses/plant-overheads, /expenses/insurance, /expenses/tracking (all empty); added /expenses/vehicles; ExpenseSectionPage now has an empty state
- B.2: captured R 157,200 Personnel block, R 100,000 Maintenance, R 10,000 Petty Cash; roll-up reconciles to R 675,162.93 (= sum of non-Plant non-Santam rows; workbook grand total R 1,110,486.87 is internally off by R 14,477.56 vs its own rows)
- B.3: seed flags incl<excl, VAT>20%, broken VAT math — does NOT silently correct; 4 anomalies reported on Charl-CCS + Buildsmart
- B.4: noted that workbook Vehicle Costs subtotal skips Santam (app R 9,673.87 is correct; workbook is wrong)
- B.2 reconciliation assertion added to verify-cost-reconciliation.mjs
- Sidebar updated: Vehicles added, Insurance/Tracking/Plant-Overheads removed
2026-09-12 09:24:18 +00:00
|
|
|
// Per Part A.2: months beyond lastActualMonth are null in `months`/`fixedMonths`/`runningMonths`.
|
|
|
|
|
// `running` is derived from monthly_running_costs; null means we have no running data for that month.
|
2026-09-12 07:35:35 +00:00
|
|
|
export function buildIncomeRows(
|
|
|
|
|
assets: Asset[],
|
|
|
|
|
income: MonthlyIncome[],
|
|
|
|
|
running?: MonthlyRunningCost[],
|
Invoices: line-item extraction, booking to plant items, VAT-inclusive income
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>
2026-10-01 13:59:30 +00:00
|
|
|
profitOpts: ProfitOptions = {},
|
|
|
|
|
// Invoice line items, used only to attribute the VAT already inside each booked
|
|
|
|
|
// amount back to the machine that earned it.
|
|
|
|
|
invoiceLines?: InvoiceLine[],
|
2026-09-12 07:35:35 +00:00
|
|
|
): IncomeRow[] {
|
Round 3 (Parts A-B): charts fixes + expenses/overheads cleanup
- A.1: fixed_expenses per month now threaded through to chart bucket
- A.2: null = no data (forecast), 0 = real plotted zero — distinguishable
- A.3: x-axis clipped to last actual month (IncomeAreaChart + IncomeBarChart + MachineLineChart)
- A.4: chart hues as CSS vars (--color-chart-income/fixed/running) — deeper steps of same hues, validated for CVD
- A.5: chart-type toggle (Lines / Cost stack / Profit) + localStorage persistence + dynamic legend
- A.6: profit on charts page comes from computeProfit()
- B.1: deleted /expenses/plant-overheads, /expenses/insurance, /expenses/tracking (all empty); added /expenses/vehicles; ExpenseSectionPage now has an empty state
- B.2: captured R 157,200 Personnel block, R 100,000 Maintenance, R 10,000 Petty Cash; roll-up reconciles to R 675,162.93 (= sum of non-Plant non-Santam rows; workbook grand total R 1,110,486.87 is internally off by R 14,477.56 vs its own rows)
- B.3: seed flags incl<excl, VAT>20%, broken VAT math — does NOT silently correct; 4 anomalies reported on Charl-CCS + Buildsmart
- B.4: noted that workbook Vehicle Costs subtotal skips Santam (app R 9,673.87 is correct; workbook is wrong)
- B.2 reconciliation assertion added to verify-cost-reconciliation.mjs
- Sidebar updated: Vehicles added, Insurance/Tracking/Plant-Overheads removed
2026-09-12 09:24:18 +00:00
|
|
|
// Determine the last actual month globally (Part A.2/A.3)
|
|
|
|
|
const lastActualMonth = income.reduce((m, r) => r.income > 0 && r.month > m ? r.month : m, 0);
|
|
|
|
|
|
2026-09-12 07:35:35 +00:00
|
|
|
const byAsset = new Map<string, IncomeRow>();
|
|
|
|
|
for (const a of assets) {
|
|
|
|
|
if (!a.code) continue;
|
|
|
|
|
byAsset.set(a.id, {
|
|
|
|
|
id: a.id, code: a.code, name: a.name,
|
2026-10-03 16:40:32 +00:00
|
|
|
isActive: a.is_active !== false,
|
Round 3 (Parts A-B): charts fixes + expenses/overheads cleanup
- A.1: fixed_expenses per month now threaded through to chart bucket
- A.2: null = no data (forecast), 0 = real plotted zero — distinguishable
- A.3: x-axis clipped to last actual month (IncomeAreaChart + IncomeBarChart + MachineLineChart)
- A.4: chart hues as CSS vars (--color-chart-income/fixed/running) — deeper steps of same hues, validated for CVD
- A.5: chart-type toggle (Lines / Cost stack / Profit) + localStorage persistence + dynamic legend
- A.6: profit on charts page comes from computeProfit()
- B.1: deleted /expenses/plant-overheads, /expenses/insurance, /expenses/tracking (all empty); added /expenses/vehicles; ExpenseSectionPage now has an empty state
- B.2: captured R 157,200 Personnel block, R 100,000 Maintenance, R 10,000 Petty Cash; roll-up reconciles to R 675,162.93 (= sum of non-Plant non-Santam rows; workbook grand total R 1,110,486.87 is internally off by R 14,477.56 vs its own rows)
- B.3: seed flags incl<excl, VAT>20%, broken VAT math — does NOT silently correct; 4 anomalies reported on Charl-CCS + Buildsmart
- B.4: noted that workbook Vehicle Costs subtotal skips Santam (app R 9,673.87 is correct; workbook is wrong)
- B.2 reconciliation assertion added to verify-cost-reconciliation.mjs
- Sidebar updated: Vehicles added, Insurance/Tracking/Plant-Overheads removed
2026-09-12 09:24:18 +00:00
|
|
|
months: Array(12).fill(null),
|
|
|
|
|
fixedMonths: Array(12).fill(null),
|
|
|
|
|
runningMonths: Array(12).fill(null),
|
2026-09-12 07:35:35 +00:00
|
|
|
profit: Array(12).fill(0),
|
|
|
|
|
income: 0, fixed: 0, running: 0, ytdProfit: 0,
|
Round 3 (Parts A-B): charts fixes + expenses/overheads cleanup
- A.1: fixed_expenses per month now threaded through to chart bucket
- A.2: null = no data (forecast), 0 = real plotted zero — distinguishable
- A.3: x-axis clipped to last actual month (IncomeAreaChart + IncomeBarChart + MachineLineChart)
- A.4: chart hues as CSS vars (--color-chart-income/fixed/running) — deeper steps of same hues, validated for CVD
- A.5: chart-type toggle (Lines / Cost stack / Profit) + localStorage persistence + dynamic legend
- A.6: profit on charts page comes from computeProfit()
- B.1: deleted /expenses/plant-overheads, /expenses/insurance, /expenses/tracking (all empty); added /expenses/vehicles; ExpenseSectionPage now has an empty state
- B.2: captured R 157,200 Personnel block, R 100,000 Maintenance, R 10,000 Petty Cash; roll-up reconciles to R 675,162.93 (= sum of non-Plant non-Santam rows; workbook grand total R 1,110,486.87 is internally off by R 14,477.56 vs its own rows)
- B.3: seed flags incl<excl, VAT>20%, broken VAT math — does NOT silently correct; 4 anomalies reported on Charl-CCS + Buildsmart
- B.4: noted that workbook Vehicle Costs subtotal skips Santam (app R 9,673.87 is correct; workbook is wrong)
- B.2 reconciliation assertion added to verify-cost-reconciliation.mjs
- Sidebar updated: Vehicles added, Insurance/Tracking/Plant-Overheads removed
2026-09-12 09:24:18 +00:00
|
|
|
lastActualMonth,
|
Invoices: line-item extraction, booking to plant items, VAT-inclusive income
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>
2026-10-01 13:59:30 +00:00
|
|
|
invoiceIncome: 0, invoiceVat: 0,
|
2026-09-12 07:35:35 +00:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
for (const r of income) {
|
|
|
|
|
const row = byAsset.get(r.asset);
|
|
|
|
|
if (!row) continue;
|
|
|
|
|
const i = r.month - 1;
|
Round 3 (Parts A-B): charts fixes + expenses/overheads cleanup
- A.1: fixed_expenses per month now threaded through to chart bucket
- A.2: null = no data (forecast), 0 = real plotted zero — distinguishable
- A.3: x-axis clipped to last actual month (IncomeAreaChart + IncomeBarChart + MachineLineChart)
- A.4: chart hues as CSS vars (--color-chart-income/fixed/running) — deeper steps of same hues, validated for CVD
- A.5: chart-type toggle (Lines / Cost stack / Profit) + localStorage persistence + dynamic legend
- A.6: profit on charts page comes from computeProfit()
- B.1: deleted /expenses/plant-overheads, /expenses/insurance, /expenses/tracking (all empty); added /expenses/vehicles; ExpenseSectionPage now has an empty state
- B.2: captured R 157,200 Personnel block, R 100,000 Maintenance, R 10,000 Petty Cash; roll-up reconciles to R 675,162.93 (= sum of non-Plant non-Santam rows; workbook grand total R 1,110,486.87 is internally off by R 14,477.56 vs its own rows)
- B.3: seed flags incl<excl, VAT>20%, broken VAT math — does NOT silently correct; 4 anomalies reported on Charl-CCS + Buildsmart
- B.4: noted that workbook Vehicle Costs subtotal skips Santam (app R 9,673.87 is correct; workbook is wrong)
- B.2 reconciliation assertion added to verify-cost-reconciliation.mjs
- Sidebar updated: Vehicles added, Insurance/Tracking/Plant-Overheads removed
2026-09-12 09:24:18 +00:00
|
|
|
// Part A.2: only null out months BEYOND the last actual month. Months up to and including
|
|
|
|
|
// lastActualMonth get their real value (which may be 0 — a real plotted zero).
|
Invoices: line-item extraction, booking to plant items, VAT-inclusive income
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>
2026-10-01 13:59:30 +00:00
|
|
|
//
|
|
|
|
|
// ACCUMULATE, do not assign: a machine can have several income rows in one month —
|
|
|
|
|
// a workbook row plus one per invoice line booked to it. Assigning made the cell show
|
|
|
|
|
// only the last row, so SS01 (two September invoices) displayed R20,700 while its own
|
|
|
|
|
// YTD total included both (R35,305), and the September column total came out short.
|
Round 3 (Parts A-B): charts fixes + expenses/overheads cleanup
- A.1: fixed_expenses per month now threaded through to chart bucket
- A.2: null = no data (forecast), 0 = real plotted zero — distinguishable
- A.3: x-axis clipped to last actual month (IncomeAreaChart + IncomeBarChart + MachineLineChart)
- A.4: chart hues as CSS vars (--color-chart-income/fixed/running) — deeper steps of same hues, validated for CVD
- A.5: chart-type toggle (Lines / Cost stack / Profit) + localStorage persistence + dynamic legend
- A.6: profit on charts page comes from computeProfit()
- B.1: deleted /expenses/plant-overheads, /expenses/insurance, /expenses/tracking (all empty); added /expenses/vehicles; ExpenseSectionPage now has an empty state
- B.2: captured R 157,200 Personnel block, R 100,000 Maintenance, R 10,000 Petty Cash; roll-up reconciles to R 675,162.93 (= sum of non-Plant non-Santam rows; workbook grand total R 1,110,486.87 is internally off by R 14,477.56 vs its own rows)
- B.3: seed flags incl<excl, VAT>20%, broken VAT math — does NOT silently correct; 4 anomalies reported on Charl-CCS + Buildsmart
- B.4: noted that workbook Vehicle Costs subtotal skips Santam (app R 9,673.87 is correct; workbook is wrong)
- B.2 reconciliation assertion added to verify-cost-reconciliation.mjs
- Sidebar updated: Vehicles added, Insurance/Tracking/Plant-Overheads removed
2026-09-12 09:24:18 +00:00
|
|
|
if (r.month <= lastActualMonth) {
|
Invoices: line-item extraction, booking to plant items, VAT-inclusive income
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>
2026-10-01 13:59:30 +00:00
|
|
|
row.months[i] = (row.months[i] ?? 0) + r.income;
|
|
|
|
|
row.fixedMonths[i] = (row.fixedMonths[i] ?? 0) + r.fixed_expenses;
|
Round 3 (Parts A-B): charts fixes + expenses/overheads cleanup
- A.1: fixed_expenses per month now threaded through to chart bucket
- A.2: null = no data (forecast), 0 = real plotted zero — distinguishable
- A.3: x-axis clipped to last actual month (IncomeAreaChart + IncomeBarChart + MachineLineChart)
- A.4: chart hues as CSS vars (--color-chart-income/fixed/running) — deeper steps of same hues, validated for CVD
- A.5: chart-type toggle (Lines / Cost stack / Profit) + localStorage persistence + dynamic legend
- A.6: profit on charts page comes from computeProfit()
- B.1: deleted /expenses/plant-overheads, /expenses/insurance, /expenses/tracking (all empty); added /expenses/vehicles; ExpenseSectionPage now has an empty state
- B.2: captured R 157,200 Personnel block, R 100,000 Maintenance, R 10,000 Petty Cash; roll-up reconciles to R 675,162.93 (= sum of non-Plant non-Santam rows; workbook grand total R 1,110,486.87 is internally off by R 14,477.56 vs its own rows)
- B.3: seed flags incl<excl, VAT>20%, broken VAT math — does NOT silently correct; 4 anomalies reported on Charl-CCS + Buildsmart
- B.4: noted that workbook Vehicle Costs subtotal skips Santam (app R 9,673.87 is correct; workbook is wrong)
- B.2 reconciliation assertion added to verify-cost-reconciliation.mjs
- Sidebar updated: Vehicles added, Insurance/Tracking/Plant-Overheads removed
2026-09-12 09:24:18 +00:00
|
|
|
}
|
2026-09-12 07:35:35 +00:00
|
|
|
row.income += r.income;
|
|
|
|
|
row.fixed += r.fixed_expenses;
|
|
|
|
|
}
|
|
|
|
|
// Per Part 1.5: running cost is derived from monthly_running_costs, not stored on income.
|
|
|
|
|
for (const c of running ?? []) {
|
|
|
|
|
const row = byAsset.get(c.asset);
|
|
|
|
|
if (!row) continue;
|
|
|
|
|
row.running += c.amount;
|
Round 3 (Parts A-B): charts fixes + expenses/overheads cleanup
- A.1: fixed_expenses per month now threaded through to chart bucket
- A.2: null = no data (forecast), 0 = real plotted zero — distinguishable
- A.3: x-axis clipped to last actual month (IncomeAreaChart + IncomeBarChart + MachineLineChart)
- A.4: chart hues as CSS vars (--color-chart-income/fixed/running) — deeper steps of same hues, validated for CVD
- A.5: chart-type toggle (Lines / Cost stack / Profit) + localStorage persistence + dynamic legend
- A.6: profit on charts page comes from computeProfit()
- B.1: deleted /expenses/plant-overheads, /expenses/insurance, /expenses/tracking (all empty); added /expenses/vehicles; ExpenseSectionPage now has an empty state
- B.2: captured R 157,200 Personnel block, R 100,000 Maintenance, R 10,000 Petty Cash; roll-up reconciles to R 675,162.93 (= sum of non-Plant non-Santam rows; workbook grand total R 1,110,486.87 is internally off by R 14,477.56 vs its own rows)
- B.3: seed flags incl<excl, VAT>20%, broken VAT math — does NOT silently correct; 4 anomalies reported on Charl-CCS + Buildsmart
- B.4: noted that workbook Vehicle Costs subtotal skips Santam (app R 9,673.87 is correct; workbook is wrong)
- B.2 reconciliation assertion added to verify-cost-reconciliation.mjs
- Sidebar updated: Vehicles added, Insurance/Tracking/Plant-Overheads removed
2026-09-12 09:24:18 +00:00
|
|
|
if (c.month <= lastActualMonth) {
|
|
|
|
|
row.runningMonths[c.month - 1] = c.amount;
|
|
|
|
|
}
|
2026-09-12 07:35:35 +00:00
|
|
|
}
|
Invoices: line-item extraction, booking to plant items, VAT-inclusive income
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>
2026-10-01 13:59:30 +00:00
|
|
|
|
|
|
|
|
// Per-month profit is computed ONCE, after income and running have both been
|
|
|
|
|
// accumulated, so it reflects the whole month rather than whichever row happened to be
|
|
|
|
|
// read last. Previously this lived inside the two loops above and inherited the same
|
|
|
|
|
// last-write-wins bug.
|
|
|
|
|
for (const row of byAsset.values()) {
|
|
|
|
|
for (let i = 0; i < 12; i++) {
|
|
|
|
|
if (i + 1 > lastActualMonth) continue;
|
|
|
|
|
row.profit[i] = computeProfit(
|
|
|
|
|
{
|
|
|
|
|
income: row.months[i] ?? 0,
|
|
|
|
|
fixed: row.fixedMonths[i] ?? 0,
|
|
|
|
|
running: row.runningMonths[i] ?? 0,
|
|
|
|
|
},
|
|
|
|
|
profitOpts,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// Attribute invoice-derived income and its embedded VAT per machine.
|
|
|
|
|
for (const l of invoiceLines ?? []) {
|
|
|
|
|
const row = byAsset.get(l.booked_asset);
|
|
|
|
|
if (!row) continue;
|
|
|
|
|
row.invoiceIncome += l.total_incl || 0;
|
|
|
|
|
row.invoiceVat += l.vat || 0;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-12 07:35:35 +00:00
|
|
|
for (const row of byAsset.values()) {
|
|
|
|
|
row.ytdProfit = computeProfit({ income: row.income, fixed: row.fixed, running: row.running }, profitOpts);
|
|
|
|
|
}
|
|
|
|
|
return Array.from(byAsset.values());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export type RunningRow = {
|
|
|
|
|
id: string;
|
|
|
|
|
code: string;
|
|
|
|
|
name: string;
|
Fold purchase orders into the Running Costs Overview, with incl/excl totals
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>
2026-10-09 10:10:13 +00:00
|
|
|
/** Per month, VAT-INCLUSIVE: the workbook figure plus any purchased parts. */
|
2026-09-12 07:35:35 +00:00
|
|
|
months: number[];
|
|
|
|
|
ytd: number;
|
Fold purchase orders into the Running Costs Overview, with incl/excl totals
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>
2026-10-09 10:10:13 +00:00
|
|
|
/** Per month, net of VAT at the confirmed rate. Shown under `months`, never instead of it. */
|
|
|
|
|
monthsExcl: number[];
|
|
|
|
|
ytdExcl: number;
|
|
|
|
|
/** The purchased portion of each month, so the Overview can say what came from where. */
|
|
|
|
|
purchasesIncl: number[];
|
Running cost = everything linked to a vehicle: repairs, purchases, insurance, finance
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>
2026-10-09 10:28:29 +00:00
|
|
|
/** False means NO cost of any kind is recorded for that month — which is different
|
|
|
|
|
* from a month that genuinely costs R0. */
|
|
|
|
|
knownMonths: boolean[];
|
|
|
|
|
byKind: { repairs: number[]; purchases: number[]; insurance: number[]; finance: number[] };
|
2026-09-12 07:35:35 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export function buildRunningRows(
|
|
|
|
|
assets: Asset[],
|
Running cost = everything linked to a vehicle: repairs, purchases, insurance, finance
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>
2026-10-09 10:28:29 +00:00
|
|
|
// All four cost kinds per machine per month — repairs, purchases, insurance and
|
|
|
|
|
// finance, assembled in lib/running-costs.ts. Everything a vehicle costs to run.
|
|
|
|
|
costs?: Map<string, MachineCosts>,
|
2026-09-12 07:35:35 +00:00
|
|
|
): RunningRow[] {
|
|
|
|
|
const byAsset = new Map<string, RunningRow>();
|
|
|
|
|
for (const a of assets) {
|
|
|
|
|
if (!a.code) continue;
|
|
|
|
|
byAsset.set(a.id, {
|
|
|
|
|
id: a.id, code: a.code, name: a.name,
|
|
|
|
|
months: Array(12).fill(0),
|
|
|
|
|
ytd: 0,
|
Running cost = everything linked to a vehicle: repairs, purchases, insurance, finance
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>
2026-10-09 10:28:29 +00:00
|
|
|
// Net of VAT at the confirmed rate, kept alongside so the Overview can show
|
|
|
|
|
// incl above excl rather than making a reader back the VAT out.
|
Fold purchase orders into the Running Costs Overview, with incl/excl totals
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>
2026-10-09 10:10:13 +00:00
|
|
|
monthsExcl: Array(12).fill(0),
|
|
|
|
|
ytdExcl: 0,
|
|
|
|
|
purchasesIncl: Array(12).fill(0),
|
Running cost = everything linked to a vehicle: repairs, purchases, insurance, finance
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>
2026-10-09 10:28:29 +00:00
|
|
|
knownMonths: Array(12).fill(false),
|
|
|
|
|
byKind: { repairs: Array(12).fill(0), purchases: Array(12).fill(0), insurance: Array(12).fill(0), finance: Array(12).fill(0) },
|
2026-09-12 07:35:35 +00:00
|
|
|
});
|
|
|
|
|
}
|
Running cost = everything linked to a vehicle: repairs, purchases, insurance, finance
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>
2026-10-09 10:28:29 +00:00
|
|
|
if (!costs) return Array.from(byAsset.values());
|
|
|
|
|
|
|
|
|
|
for (const [assetId, kinds] of costs) {
|
|
|
|
|
const row = byAsset.get(assetId);
|
2026-09-12 07:35:35 +00:00
|
|
|
if (!row) continue;
|
Running cost = everything linked to a vehicle: repairs, purchases, insurance, finance
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>
2026-10-09 10:28:29 +00:00
|
|
|
for (let i = 0; i < 12; i++) {
|
|
|
|
|
let incl = 0;
|
|
|
|
|
for (const k of COST_KINDS) {
|
|
|
|
|
const v = kinds[k][i] ?? 0;
|
|
|
|
|
incl += v;
|
|
|
|
|
row.byKind[k][i] += v;
|
|
|
|
|
}
|
|
|
|
|
if (incl <= 0) continue;
|
|
|
|
|
row.months[i] += incl;
|
|
|
|
|
row.ytd += incl;
|
|
|
|
|
row.purchasesIncl[i] += kinds.purchases[i] ?? 0;
|
|
|
|
|
row.monthsExcl[i] += exclFromIncl(incl);
|
|
|
|
|
row.ytdExcl += exclFromIncl(incl);
|
|
|
|
|
row.knownMonths[i] = true;
|
Fold purchase orders into the Running Costs Overview, with incl/excl totals
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>
2026-10-09 10:10:13 +00:00
|
|
|
}
|
2026-09-12 07:35:35 +00:00
|
|
|
}
|
|
|
|
|
return Array.from(byAsset.values());
|
|
|
|
|
}
|
Add Running Costs sub-pages: insurance, financing, purchases, oil, operators, mechanics
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>
2026-10-08 23:06:27 +00:00
|
|
|
|
|
|
|
|
export type FinancingRow = {
|
|
|
|
|
id: string;
|
|
|
|
|
code: string;
|
|
|
|
|
name: string;
|
|
|
|
|
plant: string;
|
|
|
|
|
/** Monthly instalment incl VAT, straight from the rate sheet. Not editable in the app. */
|
|
|
|
|
installment: number;
|
|
|
|
|
startYear: number;
|
|
|
|
|
startMonth: number;
|
|
|
|
|
endYear: number;
|
|
|
|
|
endMonth: number;
|
|
|
|
|
isSettled: boolean;
|
|
|
|
|
calc: FinanceCalc;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* One row per finance agreement, with its settlement maths already resolved.
|
|
|
|
|
*
|
|
|
|
|
* A machine with no agreement is left OUT, as everywhere else here: it was not
|
|
|
|
|
* financed, which is a different statement from being financed at R0.
|
|
|
|
|
*/
|
|
|
|
|
export function buildFinancingRows(
|
|
|
|
|
assets: Asset[],
|
|
|
|
|
terms: FinancingTerm[],
|
|
|
|
|
now: { year: number; month: number },
|
|
|
|
|
): FinancingRow[] {
|
|
|
|
|
const byId = new Map(assets.map((a) => [a.id, a]));
|
|
|
|
|
const out: FinancingRow[] = [];
|
|
|
|
|
for (const t of terms) {
|
|
|
|
|
const a = byId.get(t.asset);
|
|
|
|
|
if (!a || !a.code) continue;
|
|
|
|
|
out.push({
|
|
|
|
|
id: t.id,
|
|
|
|
|
code: a.code,
|
|
|
|
|
name: a.name,
|
|
|
|
|
plant: t.plant || "",
|
|
|
|
|
installment: t.installment || 0,
|
|
|
|
|
startYear: t.start_year || 0,
|
|
|
|
|
startMonth: t.start_month || 0,
|
|
|
|
|
endYear: t.end_year || 0,
|
|
|
|
|
endMonth: t.end_month || 0,
|
|
|
|
|
isSettled: Boolean(t.is_settled),
|
|
|
|
|
calc: financeCalc(toInput(t, now)),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return out;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export type OperatorRow = {
|
|
|
|
|
/** null on a row that represents an operator with no machine of their own. */
|
|
|
|
|
machineId: string | null;
|
|
|
|
|
machineCode: string;
|
|
|
|
|
machineName: string;
|
|
|
|
|
operatorId: string | null;
|
|
|
|
|
operatorName: string;
|
|
|
|
|
employeeCode: string;
|
|
|
|
|
rate: number | null;
|
|
|
|
|
/** True when this row exists because of an operator, not a machine. */
|
|
|
|
|
isUnassignedOperator: boolean;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* The operator register, keyed on the PLANT REGISTER but SCOPED to the sheet.
|
|
|
|
|
*
|
|
|
|
|
* Only machines the client's operator sheet lists appear — twenty vehicles. The
|
|
|
|
|
* register holds forty-one coded machines and the difference (containers, generators,
|
|
|
|
|
* truck trailers) can never have a person allocated to them, so listing them would
|
|
|
|
|
* present twenty-odd permanent blanks as if they were staffing gaps. The machine's
|
|
|
|
|
* NAME and CODE still come from the register, so the list stays linked to it.
|
|
|
|
|
*
|
|
|
|
|
* Every in-scope machine gets a row, whether or not anyone is on it: an unstaffed
|
|
|
|
|
* machine is exactly what a reader needs to see. A machine with no operator carries
|
|
|
|
|
* blank operator cells; that is NOT the same as an operator with no rate.
|
|
|
|
|
*
|
|
|
|
|
* Operators the sheet gave no machine get their own row at the end with blank machine
|
|
|
|
|
* cells, because they are people on the payroll and must not vanish from the register.
|
|
|
|
|
*
|
|
|
|
|
* Assets with NO code are skipped even if flagged: the sheet addresses machines by
|
|
|
|
|
* P/Unit, so an uncoded entry cannot be pointed at.
|
|
|
|
|
*/
|
|
|
|
|
export function buildOperatorRows(assets: Asset[], operators: Operator[]): OperatorRow[] {
|
|
|
|
|
const byMachine = new Map<string, Operator>();
|
|
|
|
|
const unassigned: Operator[] = [];
|
|
|
|
|
|
|
|
|
|
for (const o of operators) {
|
|
|
|
|
if (o.asset) byMachine.set(o.asset, o);
|
|
|
|
|
else unassigned.push(o);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The sheet decides the baseline, but a machine someone has been allocated to ALWAYS
|
|
|
|
|
// appears — otherwise adding an operator to a machine that was not on the sheet would
|
|
|
|
|
// look like it silently did nothing. Adding a person must always be visible.
|
|
|
|
|
const inScope = assets.filter(
|
|
|
|
|
(a) => a.code && (a.on_operator_sheet || byMachine.has(a.id)),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const rows: OperatorRow[] = inScope.map((a) => {
|
|
|
|
|
const op = byMachine.get(a.id);
|
|
|
|
|
return {
|
|
|
|
|
machineId: a.id,
|
|
|
|
|
machineCode: a.code,
|
|
|
|
|
machineName: a.name,
|
|
|
|
|
operatorId: op?.id ?? null,
|
|
|
|
|
operatorName: op?.name ?? "",
|
|
|
|
|
employeeCode: op?.employee_code ?? "",
|
|
|
|
|
rate: op ? op.rate ?? null : null,
|
|
|
|
|
isUnassignedOperator: false,
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
for (const op of unassigned) {
|
|
|
|
|
rows.push({
|
|
|
|
|
machineId: null,
|
|
|
|
|
machineCode: "",
|
|
|
|
|
machineName: "",
|
|
|
|
|
operatorId: op.id,
|
|
|
|
|
operatorName: op.name,
|
|
|
|
|
employeeCode: op.employee_code,
|
|
|
|
|
rate: op.rate ?? null,
|
|
|
|
|
isUnassignedOperator: true,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return rows;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export type InsuranceRow = {
|
|
|
|
|
id: string;
|
|
|
|
|
code: string;
|
|
|
|
|
name: string;
|
|
|
|
|
// The cost per month, resolved against the premium in force THAT month.
|
|
|
|
|
// `null` = no premium on record for that month — never 0 (see lib/insurance.ts).
|
|
|
|
|
months: (number | null)[];
|
|
|
|
|
// Sum of the months we actually know. Months with no premium are excluded rather
|
|
|
|
|
// than counted as zero, so this is never quietly smaller than a full year.
|
|
|
|
|
ytd: number;
|
|
|
|
|
coveredMonths: number;
|
|
|
|
|
currentRate: number | null;
|
|
|
|
|
// The machine's premium history, newest first — the point of the page.
|
|
|
|
|
periods: RatePeriod[];
|
|
|
|
|
problems: string[];
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* One row per insured machine, built from RATE PERIODS (`insurance_rates`), not from
|
|
|
|
|
* `cost_lines`. The rates are the source of truth; the monthly cost lines are
|
|
|
|
|
* derived from them by the seed, so reading the rates directly is what keeps this
|
|
|
|
|
* page showing a premium change rather than a flat rate re-keyed twelve times.
|
|
|
|
|
*
|
|
|
|
|
* A machine with no rate at all is left OUT, as `buildRunningRows` does for running
|
|
|
|
|
* cost: "no premium on record" is not "R0 of insurance".
|
|
|
|
|
*/
|
|
|
|
|
export function buildInsuranceRows(assets: Asset[], rates: InsuranceRate[]): InsuranceRow[] {
|
|
|
|
|
const byId = new Map(assets.map((a) => [a.id, a]));
|
|
|
|
|
const grouped = new Map<string, RatePeriod[]>();
|
|
|
|
|
|
|
|
|
|
for (const r of rates) {
|
|
|
|
|
const a = byId.get(r.asset);
|
|
|
|
|
if (!a || !a.code) continue;
|
|
|
|
|
const list = grouped.get(a.id) || [];
|
|
|
|
|
list.push({ from: r.month_from, to: r.month_to || 0, rate: r.rate, source: r.source || "" });
|
|
|
|
|
grouped.set(a.id, list);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const out: InsuranceRow[] = [];
|
|
|
|
|
for (const [assetId, periods] of grouped) {
|
|
|
|
|
const a = byId.get(assetId)!;
|
|
|
|
|
const cost = monthlyCost(periods);
|
|
|
|
|
const known = cost.filter((v): v is number => v !== null);
|
|
|
|
|
out.push({
|
|
|
|
|
id: assetId,
|
|
|
|
|
code: a.code,
|
|
|
|
|
name: a.name,
|
|
|
|
|
months: cost,
|
|
|
|
|
ytd: known.reduce((s, v) => s + v, 0),
|
|
|
|
|
coveredMonths: known.length,
|
|
|
|
|
currentRate: currentRate(periods)?.rate ?? null,
|
|
|
|
|
// Newest first — the period you most likely came to read.
|
|
|
|
|
periods: [...periods].sort((x, y) => y.from - x.from),
|
|
|
|
|
problems: validatePeriods(periods),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return out;
|
|
|
|
|
}
|