From aac440cfefddb13b5c6538b2a5279660f80d0354 Mon Sep 17 00:00:00 2001 From: David Date: Fri, 9 Oct 2026 12:10:13 +0200 Subject: [PATCH] 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) --- AGENTS.md | 32 +++++++++ app/(dashboard)/running/page.tsx | 114 +++++++++++++++++++++++++++---- components/RunningView.tsx | 46 +++++++++++-- lib/purchase-booking.ts | 87 +++++++++++++++++++++++ lib/rows.ts | 36 +++++++++- 5 files changed, 294 insertions(+), 21 deletions(-) create mode 100644 lib/purchase-booking.ts diff --git a/AGENTS.md b/AGENTS.md index 661b2da..e353029 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -485,6 +485,38 @@ Three traps this file has already hit, all in AGENTS.md terms of "do not regress Collections created by the collections API do **not** get `created`/`updated` for free — declare them explicitly or a `sort: "-created"` fails with an opaque server error. +## Running Costs Overview — purchases ARE running cost, merged at read time + +The Overview adds two sources per machine per month, both **VAT-INCLUSIVE** (client +confirmed 2026-10-09 that the workbook's figures are the amount paid): + +1. the workbook's "Plant Monthly Running Cost" (Jan–Aug 2026), and +2. uploaded **purchase orders** allocated to a machine, filed under the **PO date** + (client ruling: the month it was *ordered*, not the "Required By" month — + MEGPPO50532 is dated 07/10 but was "required" 29/08, and those must not be confused). + +**They are merged at READ time, never copied into `monthly_running_costs`.** That +collection is deleted and rebuilt by every seed, so a purchase written into it 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. + +- Only purchase lines with an `asset` are counted. An unallocated line is real money not + yet on a plant item — adding it to no machine hides it, adding it to the fleet total + overstates one. It stays on /purchases, reported as unallocated. +- A purchase with **no readable PO date** is excluded and named in a banner, never + guessed into January. + +### The incl / excl split + +`lib/purchase-booking.ts` holds `RUNNING_VAT_PCT = 15`. Purchase lines state their own +VAT% (all 14 read 15.00, and the arithmetic reconciles to exactly 15.000%), and the same +rate is applied to the workbook side because that sheet records ONE amount with no VAT +column at all. The bottom total row shows **incl above excl** for every month and for the +YTD, so nobody has to back the VAT out by hand. + +**If the workbook figures are ever restated ex-VAT, `RUNNING_VAT_PCT` is the one constant +to revisit** — every excl figure on the Overview derives from it. + ## Oil Stock — a cost code can name a STORE, not a machine `/running/oils` tracks the two bulk oils in stock and prints their drum labels. diff --git a/app/(dashboard)/running/page.tsx b/app/(dashboard)/running/page.tsx index 3a575a9..00ee1cb 100644 --- a/app/(dashboard)/running/page.tsx +++ b/app/(dashboard)/running/page.tsx @@ -1,41 +1,129 @@ import { getAssets, getRunningCosts } from "@/lib/server-data"; +import { serverAuth } from "@/lib/pb"; +import Link from "next/link"; import { ZAR } from "@/lib/utils"; -import { Wrench } from "lucide-react"; +import { Wrench, TriangleAlert } from "lucide-react"; import { RunningView } from "@/components/RunningView"; import { buildRunningRows } from "@/lib/rows"; +import { purchaseRunningCosts, RUNNING_VAT_PCT } from "@/lib/purchase-booking"; +import type { Purchase, PurchaseLine } from "@/lib/data"; // Reads PocketBase on every request. Without this Next tries to prerender the // page at build time, which makes `docker build` depend on the database being // reachable and then serves a frozen build-time snapshot to users. export const dynamic = "force-dynamic"; +/** + * Running Costs Overview — repairs & parts per machine per month. + * + * Two sources are added together, both VAT-INCLUSIVE (client confirmed 2026-10-09): + * 1. the workbook's "Plant Monthly Running Cost" (Jan–Aug), and + * 2. uploaded PURCHASE ORDERS allocated to a machine, filed under the PO month. + * + * Purchases are merged here at read time rather than copied into + * `monthly_running_costs`, which every seed deletes and rebuilds — a purchase written + * there would vanish on the next seed and have to be re-uploaded. + */ export default async function RunningPage() { - const [assets, costs] = await Promise.all([getAssets(), getRunningCosts()]); + const pb = await serverAuth(); + const [assets, costs, purchases, lines] = await Promise.all([ + getAssets(), + getRunningCosts(), + pb.collection("purchases").getFullList(), + pb.collection("purchase_lines").getFullList(), + ]); - const rows = buildRunningRows(assets, costs); + const { byAsset, undated } = purchaseRunningCosts(purchases, lines); + const rows = buildRunningRows(assets, costs, byAsset); + // Totals straight off the rows, so the summary can never disagree with the table + // underneath it. const monthTotals = Array(12).fill(0); - for (const c of costs) monthTotals[c.month - 1] += c.amount; + const monthTotalsExcl = Array(12).fill(0); + const monthPurchases = Array(12).fill(0); + for (const r of rows) { + r.months.forEach((v, i) => { + monthTotals[i] += v; + monthPurchases[i] += r.purchasesIncl[i]; + }); + r.monthsExcl.forEach((v, i) => (monthTotalsExcl[i] += v)); + } + const grand = monthTotals.reduce((s, v) => s + v, 0); + const grandExcl = monthTotalsExcl.reduce((s, v) => s + v, 0); + const purchasedTotal = monthPurchases.reduce((s, v) => s + v, 0); + const unallocated = lines.filter((l) => !l.asset).length; return ( -
-
+
+
-
+

Running Costs

-

Repairs & parts per machine per month · 2026

+

+ Repairs & parts per machine per month · 2026 +

+

+ Workbook costs plus purchase orders allocated to a machine, filed under the + month the PO was raised. All figures incl VAT, with the excl figure derived + at {RUNNING_VAT_PCT}%. +

-
-
YTD running cost
-
{ZAR(monthTotals.reduce((s, v) => s + v, 0))}
+
+
YTD running cost · incl VAT
+
{ZAR(grand)}
+
+ excl VAT {ZAR(grandExcl)} +
- + {purchasedTotal > 0 && ( +
+ + {ZAR(purchasedTotal)} of this comes from uploaded purchase orders + {" "} + allocated to a machine. + {unallocated > 0 && ( + <> + {" "} + {unallocated} purchase line{unallocated !== 1 && "s"} are still unallocated + and are not in these totals — see{" "} + {/* Inline in a sentence, but the hit area still meets the 44px minimum. + `-my-4` cancels the vertical padding back out, so the sentence's line + spacing is unchanged while the tap target reaches 48px. */} + + Purchases + + . + + )} +
+ )} + + {undated.length > 0 && ( +
+ + + + {undated.length} purchase line{undated.length !== 1 && "s"} could not be filed + {" "} + — no readable PO date ({undated.slice(0, 3).join(", ")}). They are excluded from + the monthly totals rather than guessed into January. + +
+ )} + +
); -} +} \ No newline at end of file diff --git a/components/RunningView.tsx b/components/RunningView.tsx index 8fabacb..04882fc 100644 --- a/components/RunningView.tsx +++ b/components/RunningView.tsx @@ -11,10 +11,16 @@ export function RunningView({ rows, palette, monthTotals, + monthTotalsExcl, + vatPct, }: { rows: RunningRow[]; palette: { income: string; profit: string; loss: string }; + /** Per month, VAT-INCLUSIVE. */ monthTotals: number[]; + /** The same months net of VAT, shown UNDER each incl figure. */ + monthTotalsExcl?: number[]; + vatPct?: number; }) { const [view, setView] = useState<"cards" | "grid">("cards"); const [sort, setSort] = useState<"ytd" | "code">("ytd"); @@ -97,7 +103,7 @@ export function RunningView({ {view === "cards" ? ( ) : ( - + s + v, 0)} /> )} ); @@ -182,11 +188,14 @@ function CardsView({ } function GridView({ - rows, monthTotals, grandTotal, + rows, monthTotals, monthTotalsExcl, vatPct, grandTotal, grandExcl, }: { rows: RunningRow[]; monthTotals: number[]; + monthTotalsExcl?: number[]; + vatPct?: number; grandTotal: number; + grandExcl: number; }) { return (
@@ -226,15 +235,38 @@ function GridView({ ))} + {/* Bottom row: the month's total on BOTH bases, incl ABOVE excl, so a + reader never has to back the VAT out by hand. */} - Total + + Total + + incl / excl + + {monthTotals.map((v, i) => ( - - {v > 0 ? ZAR(v) : "—"} + + {v > 0 ? ( + <> + {ZAR(v)} + {monthTotalsExcl && ( + + {ZAR(monthTotalsExcl[i] ?? 0)} + + )} + + ) : ( + — + )} ))} - - {ZAR(grandTotal)} + + {ZAR(grandTotal)} + {grandExcl > 0 && ( + + {ZAR(grandExcl)} + + )} diff --git a/lib/purchase-booking.ts b/lib/purchase-booking.ts new file mode 100644 index 0000000..186d4d3 --- /dev/null +++ b/lib/purchase-booking.ts @@ -0,0 +1,87 @@ +import type { Purchase, PurchaseLine } from "./data"; + +/** + * Purchases are running cost. A PO buys parts and repairs against a plant item, so + * its money belongs in the same per-machine, per-month series as the workbook's + * "Plant Monthly Running Cost" — and the Overview is where somebody reads the total + * for a month. + * + * They are MERGED AT READ TIME rather than copied into `monthly_running_costs`, for + * one decisive reason: that collection is deleted and rebuilt by every seed, so any + * purchase written into it would vanish on the next one and have to be re-imported. + * `purchase_lines` is never deleted, so the money lives in one place and the Overview + * simply adds it up. + */ + +/** + * VAT rate used to split a workbook running-cost figure, which records ONE amount + * with no VAT column (client confirmed 2026-10-09 that it is the amount paid, i.e. + * VAT-INCLUSIVE). Purchase lines state their own VAT% — all 14 read 15.00 — so the + * same rate is applied to the workbook side to produce a comparable excl figure. + */ +export const RUNNING_VAT_PCT = 15; + +/** Split a VAT-inclusive amount into its net value. */ +export function exclFromIncl(incl: number, vatPct: number = RUNNING_VAT_PCT): number { + return incl / (1 + vatPct / 100); +} + +/** One machine's spend in one month, on both bases. */ +export type RunningSlice = { incl: number; excl: number }; + +/** The month a purchase order lands in: the PO date, i.e. the month it was ordered. */ +function poMonth(p: Purchase): number | null { + const m = /^(\d{4})-(\d{2})/.exec(p.po_date || ""); + if (!m) return null; + const month = Number(m[2]); + return month >= 1 && month <= 12 ? month : null; +} + +/** + * Per-machine, per-month spend from uploaded purchase orders. + * + * Only lines ALLOCATED to a machine are counted. An unallocated line is real money + * that is not yet on a plant item — adding it to no machine would hide it, and adding + * it to the fleet total would overstate a specific machine. It stays on /purchases, + * reported as unallocated, until it is pointed somewhere. + * + * A purchase with no readable PO date is skipped and surfaced in `undated`, rather + * than being quietly filed under January. + */ +export function purchaseRunningCosts( + purchases: Purchase[], + lines: PurchaseLine[], + year = 2026, +): { + byAsset: Map; + undated: string[]; +} { + const poById = new Map(purchases.map((p) => [p.id, p])); + const byAsset = new Map(); + const undated: string[] = []; + + for (const line of lines) { + if (!line.asset) continue; + const po = poById.get(line.purchase); + if (!po) continue; + if (po.year && po.year !== year) continue; + + const month = poMonth(po); + if (month === null) { + const label = `${po.po_no} #${line.line_no}`; + if (!undated.includes(label)) undated.push(label); + continue; + } + + const incl = line.total_incl ?? line.amount_excl ?? 0; + const excl = line.amount_excl ?? exclFromIncl(incl); + const months = byAsset.get(line.asset) || Array(12).fill(null); + const at = months[month - 1]; + months[month - 1] = at + ? { incl: at.incl + incl, excl: at.excl + excl } + : { incl, excl }; + byAsset.set(line.asset, months); + } + + return { byAsset, undated }; +} \ No newline at end of file diff --git a/lib/rows.ts b/lib/rows.ts index 8989b7c..472a9e6 100644 --- a/lib/rows.ts +++ b/lib/rows.ts @@ -2,6 +2,7 @@ import type { Asset, FinancingTerm, InsuranceRate, InvoiceLine, MonthlyIncome, M import { computeProfit, type ProfitOptions } from "./profit"; import { currentRate, monthlyCost, validatePeriods, type RatePeriod } from "./insurance"; import { financeCalc, toInput, type FinanceCalc } from "./financing"; +import { exclFromIncl, type RunningSlice } from "./purchase-booking"; export type IncomeRow = { id: string; @@ -121,13 +122,23 @@ export type RunningRow = { id: string; code: string; name: string; + /** Per month, VAT-INCLUSIVE: the workbook figure plus any purchased parts. */ months: number[]; ytd: number; + /** 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[]; }; export function buildRunningRows( assets: Asset[], - costs: MonthlyRunningCost[] + costs: MonthlyRunningCost[], + // Purchases merged in on top of the workbook, per machine and month, VAT-INCLUSIVE + // (see lib/purchase-booking.ts). Optional so the shape stays usable where only the + // workbook is loaded. + purchases?: Map, ): RunningRow[] { const byAsset = new Map(); for (const a of assets) { @@ -136,6 +147,11 @@ export function buildRunningRows( id: a.id, code: a.code, name: a.name, months: Array(12).fill(0), ytd: 0, + // Net of VAT, derived at the confirmed rate. Kept alongside so the Overview can + // show incl above excl rather than making the reader back the VAT out. + monthsExcl: Array(12).fill(0), + ytdExcl: 0, + purchasesIncl: Array(12).fill(0), }); } for (const c of costs) { @@ -143,6 +159,24 @@ export function buildRunningRows( if (!row) continue; row.months[c.month - 1] = c.amount; row.ytd += c.amount; + row.monthsExcl[c.month - 1] = exclFromIncl(c.amount); + row.ytdExcl += exclFromIncl(c.amount); + } + if (purchases) { + for (const [assetId, months] of purchases) { + const row = byAsset.get(assetId); + if (!row) continue; + months.forEach((slice, i) => { + if (slice == null) return; + // Use the excl the PO line actually carries rather than backing it out of the + // incl — the document states both, so there is no reason to re-derive one. + row.months[i] += slice.incl; + row.ytd += slice.incl; + row.purchasesIncl[i] += slice.incl; + row.monthsExcl[i] += slice.excl; + row.ytdExcl += slice.excl; + }); + } } return Array.from(byAsset.values()); }