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>
This commit is contained in:
David 2026-10-09 12:10:13 +02:00
parent f85b9378a2
commit aac440cfef
5 changed files with 294 additions and 21 deletions

View file

@ -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.

View file

@ -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<Purchase>(),
pb.collection("purchase_lines").getFullList<PurchaseLine>(),
]);
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 (
<div className="p-8 max-w-[1400px]">
<div className="flex items-end justify-between mb-6">
<div className="p-4 sm:p-8 max-w-[1400px]">
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-6">
<div>
<div className="flex items-center gap-3 mb-1">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[#EDAE49]/15 to-[#D1495B]/15 grid place-items-center text-[#EDAE49]">
<div className="w-10 h-10 shrink-0 rounded-xl bg-gradient-to-br from-[#EDAE49]/15 to-[#D1495B]/15 grid place-items-center text-[#EDAE49]">
<Wrench className="w-5 h-5" />
</div>
<h1 className="text-2xl font-semibold text-[#003D5B] dark:text-white">Running Costs</h1>
</div>
<p className="text-gray-500 dark:text-gray-400 text-sm">Repairs & parts per machine per month · 2026</p>
<p className="text-gray-500 dark:text-gray-400 text-sm">
Repairs &amp; parts per machine per month · 2026
</p>
<p className="text-xs text-gray-400 dark:text-text-soft mt-1">
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}%.
</p>
</div>
<div className="bg-gradient-to-br from-[#EDAE49] to-[#D1495B] rounded-2xl px-5 py-3 text-white shadow-lg shadow-orange-500/20">
<div className="text-xs text-white/80">YTD running cost</div>
<div className="text-2xl font-semibold tabular-nums">{ZAR(monthTotals.reduce((s, v) => s + v, 0))}</div>
<div className="bg-gradient-to-br from-[#EDAE49] to-[#D1495B] rounded-2xl px-5 py-3 text-white shadow-lg shadow-orange-500/20 self-start sm:self-auto">
<div className="text-xs text-white/80">YTD running cost · incl VAT</div>
<div className="text-2xl font-semibold tabular-nums">{ZAR(grand)}</div>
<div className="text-xs text-white/75 tabular-nums mt-0.5">
excl VAT {ZAR(grandExcl)}
</div>
</div>
</div>
<RunningView rows={rows} palette={{ income: "#EDAE49", profit: "#EDAE49", loss: "#EDAE49" }} monthTotals={monthTotals} />
{purchasedTotal > 0 && (
<div className="mb-4 rounded-2xl border border-[#30638E]/25 bg-[#30638E]/[0.05] p-4 text-xs text-gray-600 dark:text-gray-400">
<span className="font-semibold text-[#003D5B] dark:text-white">
{ZAR(purchasedTotal)} of this comes from uploaded purchase orders
</span>{" "}
allocated to a machine.
{unallocated > 0 && (
<>
{" "}
{unallocated} purchase line{unallocated !== 1 && "s"} are still unallocated
and are <span className="font-medium">not</span> 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. */}
<Link href="/running/purchases" className="underline inline-block py-4 -my-4">
Purchases
</Link>
.
</>
)}
</div>
)}
{undated.length > 0 && (
<div className="mb-4 flex items-start gap-2 rounded-2xl border border-[#EDAE49]/30 bg-[#EDAE49]/[0.06] p-4 text-xs text-gray-600 dark:text-gray-400">
<TriangleAlert className="w-4 h-4 mt-0.5 shrink-0 text-[#EDAE49]" />
<span>
<span className="font-semibold text-[#003D5B] dark:text-white">
{undated.length} purchase line{undated.length !== 1 && "s"} could not be filed
</span>{" "}
— no readable PO date ({undated.slice(0, 3).join(", ")}). They are excluded from
the monthly totals rather than guessed into January.
</span>
</div>
)}
<RunningView
rows={rows}
palette={{ income: "#EDAE49", profit: "#EDAE49", loss: "#EDAE49" }}
monthTotals={monthTotals}
monthTotalsExcl={monthTotalsExcl}
vatPct={RUNNING_VAT_PCT}
/>
</div>
);
}
}

View file

@ -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" ? (
<CardsView rows={sorted} expanded={expanded} setExpanded={setExpanded} palette={palette} />
) : (
<GridView rows={sorted} monthTotals={monthTotals} grandTotal={grandTotal} />
<GridView rows={sorted} monthTotals={monthTotals} monthTotalsExcl={monthTotalsExcl} vatPct={vatPct} grandTotal={grandTotal} grandExcl={(monthTotalsExcl || []).reduce((s, v) => 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 (
<div className="bg-white dark:bg-surface rounded-2xl border border-gray-100 dark:border-border shadow-sm overflow-hidden">
@ -226,15 +235,38 @@ function GridView({
</td>
</tr>
))}
{/* Bottom row: the month's total on BOTH bases, incl ABOVE excl, so a
reader never has to back the VAT out by hand. */}
<tr className="bg-gradient-to-r from-[#EDAE49]/10 dark:from-[#EDAE49]/15 to-transparent border-t-2 border-[#EDAE49]/30">
<td colSpan={2} className="py-3 px-6 font-semibold text-right text-[#003D5B] dark:text-white">Total</td>
<td colSpan={2} className="py-3 px-6 font-semibold text-right align-bottom text-[#003D5B] dark:text-white">
Total
<span className="block text-[12px] font-normal text-gray-400 dark:text-text-soft">
incl / excl
</span>
</td>
{monthTotals.map((v, i) => (
<td key={i} className="py-3 px-2 text-right tabular-nums font-semibold text-[#003D5B] dark:text-white">
{v > 0 ? ZAR(v) : "—"}
<td key={i} className="py-3 px-2 text-right tabular-nums align-bottom">
{v > 0 ? (
<>
<span className="block font-semibold text-[#003D5B] dark:text-white">{ZAR(v)}</span>
{monthTotalsExcl && (
<span className="block text-[12px] font-normal text-gray-400 dark:text-text-soft">
{ZAR(monthTotalsExcl[i] ?? 0)}
</span>
)}
</>
) : (
<span className="text-[#003D5B] dark:text-white">—</span>
)}
</td>
))}
<td className="py-3 px-6 text-right tabular-nums font-bold text-base text-[#EDAE49]">
{ZAR(grandTotal)}
<td className="py-3 px-6 text-right tabular-nums align-bottom">
<span className="block font-bold text-base text-[#EDAE49]">{ZAR(grandTotal)}</span>
{grandExcl > 0 && (
<span className="block text-[12px] font-normal text-gray-400 dark:text-text-soft">
{ZAR(grandExcl)}
</span>
)}
</td>
</tr>
</tbody>

87
lib/purchase-booking.ts Normal file
View file

@ -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<string, (RunningSlice | null)[]>;
undated: string[];
} {
const poById = new Map(purchases.map((p) => [p.id, p]));
const byAsset = new Map<string, (RunningSlice | null)[]>();
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 };
}

View file

@ -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<string, (RunningSlice | null)[]>,
): RunningRow[] {
const byAsset = new Map<string, RunningRow>();
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());
}