Stop the Running Costs grid at the current month

Insurance and finance genuinely recur, so left to fill all twelve months they made
November and December look known — ~R470k a month of phantom figures for months that
have not started, which is the "it will confuse the people" case exactly.

Both halves of the fix are needed:

- machineRunningCosts() caps every source at the horizon, so no phantom figure is even
  computed. YTD is therefore genuinely year-to-date and moves as the year goes on.
- RunningView renders only that many columns. Future months are NOT rendered empty:
  an empty column still reads as "we have a figure of zero", which is the same
  confusion in a quieter form.

October is shown and is expected to grow as repairs and purchases land against a
vehicle. A PO dated beyond the horizon is skipped silently, which is not the same as
an undated PO — only a genuinely unreadable date is reported.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
David 2026-10-09 12:35:52 +02:00
parent 4a76b08617
commit b0c70e9210
4 changed files with 68 additions and 16 deletions

View file

@ -529,6 +529,28 @@ the money lives in one place and the Overview adds it up.
- A purchase with **no readable PO date** is excluded and named in a banner, never
guessed into January.
### Never show a month ahead
**The grid stops at the month we are IN (client ruling, 2026-10-09).** Insurance and
finance genuinely recur, so left alone they would quietly fill November and December
with ~R470k a month and make the sheet look like the year is known when those months
have not started — "it will confuse the people".
Two things enforce it, and both are needed:
1. `machineRunningCosts(..., lastMonth)` caps every source at the horizon, so no
phantom figure is even computed. The **YTD is therefore year-to-date, not
twelve months** — it moves as the year goes on.
2. `RunningView` takes `visibleMonths` and renders only that many columns. The future
months are NOT rendered empty — an empty column still reads as "we have a figure of
zero", which is the same confusion in a quieter form.
The current month IS shown and is expected to **grow** as repairs and purchases are
entered against a vehicle; that is the point of showing it live.
⚠️ A PO dated beyond the horizon is skipped silently, which is NOT the same as an
undated PO. Only a genuinely unreadable date is reported as undated.
### The incl / excl split
`lib/purchase-booking.ts` holds `RUNNING_VAT_PCT = 15`. Purchase lines state their own

View file

@ -5,6 +5,7 @@ import { ZAR } from "@/lib/utils";
import { Wrench, TriangleAlert } from "lucide-react";
import { RunningView } from "@/components/RunningView";
import { buildRunningRows } from "@/lib/rows";
import { MONTH_NAMES } from "@/lib/data";
import { machineRunningCosts, RUNNING_VAT_PCT } from "@/lib/running-costs";
import type {
FinancingTerm, InsuranceRate, MonthlyRunningCost, Purchase, PurchaseLine,
@ -40,9 +41,13 @@ export default async function RunningPage() {
pb.collection("financing_terms").getFullList<FinancingTerm>(),
]);
// Never project past the month we are IN — see lib/running-costs.ts.
const now = new Date();
const lastMonth = now.getFullYear() === 2026 ? now.getMonth() + 1 : 12;
const { byAsset, undatedPurchases } = machineRunningCosts({
assets, repairs, purchases, purchaseLines: lines, insurance, finance,
});
}, lastMonth);
const rows = buildRunningRows(assets, byAsset);
const monthTotals = Array(12).fill(0);
@ -71,6 +76,11 @@ export default async function RunningPage() {
<p className="text-gray-500 dark:text-gray-400 text-sm">
Everything a vehicle costs to run · repairs, purchases, insurance, finance · 2026
</p>
<p className="text-xs text-gray-400 dark:text-text-soft mt-1">
Shown to {MONTH_NAMES.slice(0, lastMonth).join(", ")}. A month ahead is not shown —
it cannot be calculated, only guessed at, and the last month keeps growing as
things are entered.
</p>
<p className="text-xs text-gray-400 dark:text-text-soft mt-1">
A re-cut of costs already counted in fixed expenses — do not add this to them.
Figures are incl VAT, with the excl figure derived at {RUNNING_VAT_PCT}%.
@ -116,6 +126,7 @@ export default async function RunningPage() {
monthTotals={monthTotals}
monthTotalsExcl={monthTotalsExcl}
byKind={monthByKind}
visibleMonths={lastMonth}
/>
</div>
);

View file

@ -24,6 +24,7 @@ export function RunningView({
monthTotals,
monthTotalsExcl,
byKind,
visibleMonths,
}: {
rows: RunningRow[];
palette: { income: string; profit: string; loss: string };
@ -33,6 +34,12 @@ export function RunningView({
monthTotalsExcl?: number[];
/** Per-month split behind the total, offered on hover rather than as columns. */
byKind?: { repairs: number[]; purchases: number[]; insurance: number[]; finance: number[] };
/**
* How many months of the year to RENDER. A month ahead cannot be calculated, only
* guessed at, so the columns for it are not shown at all rather than shown empty
* (client ruling, 2026-10-09).
*/
visibleMonths?: number;
}) {
const [view, setView] = useState<"cards" | "grid">("cards");
const [sort, setSort] = useState<"ytd" | "code">("ytd");
@ -113,18 +120,19 @@ export function RunningView({
</div>
{view === "cards" ? (
<CardsView rows={sorted} expanded={expanded} setExpanded={setExpanded} palette={palette} />
<CardsView rows={sorted} expanded={expanded} setExpanded={setExpanded} palette={palette} visibleMonths={visibleMonths} />
) : (
<GridView rows={sorted} monthTotals={monthTotals} monthTotalsExcl={monthTotalsExcl} byKind={byKind} grandTotal={grandTotal} grandExcl={(monthTotalsExcl || []).reduce((s, v) => s + v, 0)} />
<GridView rows={sorted} monthTotals={monthTotals} monthTotalsExcl={monthTotalsExcl} byKind={byKind} visibleMonths={visibleMonths} grandTotal={grandTotal} grandExcl={(monthTotalsExcl || []).reduce((s, v) => s + v, 0)} />
)}
</>
);
}
function CardsView({
rows, expanded, setExpanded, palette,
rows, expanded, setExpanded, palette, visibleMonths,
}: {
rows: RunningRow[];
visibleMonths?: number;
expanded: string | null;
setExpanded: (id: string | null) => void;
palette: { income: string; profit: string; loss: string };
@ -177,7 +185,7 @@ function CardsView({
<div className="border-t border-gray-100 dark:border-border px-4 py-4 bg-gray-50/50 dark:bg-white/[0.04]">
{/* Mobile: 3 cols (4 rows × 3 cols = 12 months). md+: 6 cols. lg+: 12 cols. */}
<div className="grid grid-cols-3 md:grid-cols-6 lg:grid-cols-12 gap-2 text-[11px]">
{r.months.map((v, i) => (
{r.months.slice(0, visibleMonths ?? 12).map((v, i) => (
<div key={i} className="text-center">
<div className="text-gray-400 dark:text-text-soft text-[10px] mb-1">{MONTH_NAMES[i]}</div>
<div className={`py-1.5 rounded tabular-nums ${v > 0 ? "bg-[#EDAE49]/5 text-[#EDAE49] dark:bg-[#EDAE49]/15" : "text-gray-300 dark:text-text-soft"}`}>
@ -200,12 +208,13 @@ function CardsView({
}
function GridView({
rows, monthTotals, monthTotalsExcl, byKind, grandTotal, grandExcl,
rows, monthTotals, monthTotalsExcl, byKind, visibleMonths, grandTotal, grandExcl,
}: {
rows: RunningRow[];
monthTotals: number[];
monthTotalsExcl?: number[];
byKind?: { repairs: number[]; purchases: number[]; insurance: number[]; finance: number[] };
visibleMonths?: number;
grandTotal: number;
grandExcl: number;
}) {
@ -234,7 +243,7 @@ function GridView({
<tr className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider border-y border-gray-100 dark:border-border bg-gray-50/50 dark:bg-white/[0.04]">
<th className="text-left py-3 px-6 font-medium sticky left-0 bg-gray-50/95 dark:bg-surface-muted/95 backdrop-blur z-10">Code</th>
<th className="text-left py-3 px-3 font-medium">Machine</th>
{MONTH_NAMES.map((m) => (
{MONTH_NAMES.slice(0, visibleMonths ?? 12).map((m) => (
<th key={m} className="text-right py-3 px-2 font-medium min-w-[78px]">{m}</th>
))}
<th className="text-right py-3 px-6 font-medium">YTD</th>
@ -254,7 +263,7 @@ function GridView({
{r.name}
</div>
</td>
{r.months.map((v, i) => (
{r.months.slice(0, visibleMonths ?? 12).map((v, i) => (
<td key={i} className={`py-2.5 px-2 text-right tabular-nums ${v > 0 ? "text-gray-700 dark:text-gray-300" : "text-gray-300 dark:text-text-soft"}`}>
{v > 0 ? ZAR2(v) : "—"}
</td>
@ -273,7 +282,7 @@ function GridView({
incl / excl
</span>
</td>
{monthTotals.map((v, i) => (
{monthTotals.slice(0, visibleMonths ?? 12).map((v, i) => (
<td
key={i}
title={byKind ? kindTitle(byKind, i) : undefined}

View file

@ -71,12 +71,17 @@ function poMonth(p: Purchase, year: number): number | null {
* client's current sheet (18 agreements — R428 585,04/mo). Using both double-counts
* the four shared machines, so the current sheet wins and the stale rows are ignored.
*/
export function machineRunningCosts(input: RunningCostInputs): {
export function machineRunningCosts(input: RunningCostInputs, lastMonth?: number): {
byAsset: Map<string, MachineCosts>;
undatedPurchases: string[];
financeFrom: string;
} {
const year = input.year ?? 2026;
// Never project past the month we are IN. Insurance and finance recur, so left alone
// they would silently fill November and December with ~R470k a month and make the
// sheet look like the year is known when those months have not started (client
// ruling, 2026-10-09). A month ahead cannot be calculated, only guessed at.
const horizon = Math.min(12, Math.max(1, lastMonth ?? 12));
const repairs = new Map<string, (number | null)[]>();
const purchases = new Map<string, (number | null)[]>();
@ -85,7 +90,9 @@ export function machineRunningCosts(input: RunningCostInputs): {
const undatedPurchases: string[] = [];
// 1. Repairs & parts — the workbook bootstrap, still the only repair history there is.
for (const r of input.repairs) add(repairs, r.asset, r.month, r.amount);
for (const r of input.repairs) {
if (r.month <= horizon) add(repairs, r.asset, r.month, r.amount);
}
// 2. Purchase orders — only lines allocated to a machine. An unallocated line is
// real money not yet on a plant item; it stays on /purchases rather than being
@ -97,10 +104,13 @@ export function machineRunningCosts(input: RunningCostInputs): {
if (!po) continue;
const month = poMonth(po, year);
if (month === null) {
// Genuinely undated — reported, never guessed into January.
const label = `${po.po_no} #${line.line_no}`;
if (!undatedPurchases.includes(label)) undatedPurchases.push(label);
continue;
}
// Beyond the horizon: simply not shown yet, which is NOT the same as undated.
if (month > horizon) continue;
add(purchases, line.asset, month, line.total_incl ?? line.amount_excl ?? 0);
}
@ -108,16 +118,16 @@ export function machineRunningCosts(input: RunningCostInputs): {
// charged at the price that applied that month rather than smeared across the year.
for (const rate of input.insurance) {
if (rate.year !== year) continue;
const end = rate.month_to || 12;
const end = Math.min(rate.month_to || 12, horizon);
for (let m = rate.month_from; m <= end; m++) add(insurance, rate.asset, m, rate.rate);
}
// 4. Finance — a MONTHLY instalment, so it recurs for every month of the year, and
// only while the agreement is live. Settled agreements have stopped, so they
// contribute nothing: a paid-off machine is not a running cost.
// 4. Finance — a MONTHLY instalment, so it recurs every month, and only while the
// agreement is live. Settled agreements have stopped, so they contribute nothing:
// a paid-off machine is not a running cost.
for (const t of input.finance) {
if (t.is_settled) continue;
for (let m = 1; m <= 12; m++) add(finance, t.asset, m, t.installment || 0);
for (let m = 1; m <= horizon; m++) add(finance, t.asset, m, t.installment || 0);
}
const byAsset = new Map<string, MachineCosts>();