mega-plant/app/(dashboard)/running/page.tsx
David b0c70e9210 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>
2026-10-09 12:35:52 +02:00

133 lines
No EOL
6.4 KiB
TypeScript

import Link from "next/link";
import { getAssets } from "@/lib/server-data";
import { serverAuth } from "@/lib/pb";
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,
} 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 — everything a vehicle costs to run, per machine per month.
*
* Client ruling, 2026-10-09: insurance, finance, purchases and repairs ALL belong here.
* A machine with no repairs and no purchase order still carries an insurance premium
* and a monthly instalment while it is financed, so it still has a figure.
*
* Everything is read from POCKETBASE — the registers the client maintains in the app.
* The original spreadsheet only bootstrapped the page; it is not a source here.
*
* ⚠️ This is a RE-CUT, not new spend: insurance and instalments are already inside
* `monthly_income.fixed_expenses`. Adding this total to fixed expenses would count the
* same premium twice.
*/
export default async function RunningPage() {
const pb = await serverAuth();
const [assets, repairs, purchases, lines, insurance, finance] = await Promise.all([
getAssets(),
pb.collection("monthly_running_costs").getFullList<MonthlyRunningCost>(),
pb.collection("purchases").getFullList<Purchase>(),
pb.collection("purchase_lines").getFullList<PurchaseLine>(),
pb.collection("insurance_rates").getFullList<InsuranceRate>(),
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);
const monthTotalsExcl = Array(12).fill(0);
const monthByKind = { repairs: Array(12).fill(0), purchases: Array(12).fill(0), insurance: Array(12).fill(0), finance: Array(12).fill(0) };
for (const r of rows) {
r.months.forEach((v, i) => (monthTotals[i] += v));
r.monthsExcl.forEach((v, i) => (monthTotalsExcl[i] += v));
for (const k of ["repairs", "purchases", "insurance", "finance"] as const)
r.byKind[k].forEach((v, i) => (monthByKind[k][i] += v));
}
const grand = monthTotals.reduce((s, v) => s + v, 0);
const grandExcl = monthTotalsExcl.reduce((s, v) => s + v, 0);
const unallocated = lines.filter((l) => !l.asset).length;
return (
<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 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">
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}%.
</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 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>
{undatedPurchases.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">
{undatedPurchases.length} purchase line{undatedPurchases.length !== 1 && "s"} could not be filed
</span>{" "}
— no readable PO date ({undatedPurchases.slice(0, 3).join(", ")}). Excluded rather than
guessed into January.
</span>
</div>
)}
{unallocated > 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">
{unallocated} purchase line{unallocated !== 1 && "s"} not yet allocated
</span>{" "}
to a machine, so they are{" "}
<span className="font-medium">not</span> in these totals — see{" "}
<Link href="/running/purchases" className="underline inline-block py-4 -my-4">
Purchases
</Link>
.
</div>
)}
<RunningView
rows={rows}
palette={{ income: "#EDAE49", profit: "#EDAE49", loss: "#EDAE49" }}
monthTotals={monthTotals}
monthTotalsExcl={monthTotalsExcl}
byKind={monthByKind}
visibleMonths={lastMonth}
/>
</div>
);
}