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>
133 lines
No EOL
6.4 KiB
TypeScript
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>
|
|
);
|
|
} |