The four kinds under running cost - repairs & parts, purchases, insurance and finance - ARE the whole of a vehicle's running cost. That was true of the arithmetic but not of the screen: a card showed one number with no visible parts, and the only breakdown was a hover tooltip on the grid's total row. - Each card now shows its four parts, so what the vehicle's spend is made of is visible without expanding it. A vehicle with no spend shows nothing: "nothing recorded" is already on the card. - The expanded panel shows the four parts against the vehicle's total. - The grid's footer shows the fleet's four parts above the fleet total, so a month column adds up on the screen instead of on trust. - The page header states the fleet's four parts and the total side by side. The parts ARE the spend and there is no fifth source, so the parts and the total can never disagree. Any change that touches a cost kind must keep that additivity true. Also: the labels on this page were text-[10px], under the 12px floor the mobile checklist sets. They are now 12px. Two rulings recorded in AGENTS.md: - FUEL IS NOT A PLANT RUNNING COST. The sites are invoiced for the fuel they use. The only fuel that is a plant cost is the fuel a plant vehicle takes from the tank for plant work, so a fuel log is never a running-cost source however many litres it records. - Only what is under running cost as it stands. The workbook's per-vehicle fixed cost also carries depreciation/ownership and an operator allocation; that is capital recovery and overhead, not running cost, so it stays out - and the vehicles it covers (the excavator, the site and office containers, the generators, the trailers) correctly show no running cost on the Overview.
375 lines
18 KiB
TypeScript
375 lines
18 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useMemo, useRef, useEffect } from "react";
|
||
import { ScrollFade } from "@/components/ScrollFade";
|
||
import { Wrench, LayoutGrid, Sparkles, ChevronRight } from "lucide-react";
|
||
import { Sparkline } from "@/components/Sparkline";
|
||
import { MONTH_NAMES } from "@/lib/data";
|
||
import { COST_KINDS, COST_KIND_LABELS } from "@/lib/running-costs";
|
||
import type { RunningRow } from "@/lib/rows";
|
||
import { ZAR, ZAR2 } from "@/lib/utils";
|
||
|
||
/** Month total split, for the cell tooltip. Rounded — raw float sums read as
|
||
* "41944.159999999996", which looks like a bug even though it is arithmetic. */
|
||
function kindTitle(byKind: { repairs: number[]; purchases: number[]; insurance: number[]; finance: number[] }, i: number) {
|
||
const r2 = (n: number) => n.toFixed(2).replace(/\.00$/, "");
|
||
return (
|
||
`Repairs R${r2(byKind.repairs[i])} · Purchases R${r2(byKind.purchases[i])}` +
|
||
` · Insurance R${r2(byKind.insurance[i])} · Finance R${r2(byKind.finance[i])}`
|
||
);
|
||
}
|
||
|
||
/** The four cost kinds. These ARE a vehicle's running cost — there is no fifth source —
|
||
* so the parts always add up to the vehicle's spend and the vehicles add up to the
|
||
* Overview. Showing them is what makes that checkable rather than a claim. */
|
||
const KINDS = COST_KINDS;
|
||
|
||
export function RunningView({
|
||
rows,
|
||
palette,
|
||
monthTotals,
|
||
monthTotalsExcl,
|
||
byKind,
|
||
visibleMonths,
|
||
}: {
|
||
rows: RunningRow[];
|
||
palette: { income: string; profit: string; loss: string };
|
||
/** Per month, VAT-INCLUSIVE, all cost kinds combined. */
|
||
monthTotals: number[];
|
||
/** The same months net of VAT, shown UNDER each incl figure. */
|
||
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");
|
||
const [expanded, setExpanded] = useState<string | null>(null);
|
||
|
||
const sorted = useMemo(() => {
|
||
return [...rows].sort((a, b) =>
|
||
sort === "ytd" ? b.ytd - a.ytd : a.code.localeCompare(b.code)
|
||
);
|
||
}, [rows, sort]);
|
||
|
||
const grandTotal = monthTotals.reduce((s, v) => s + v, 0);
|
||
|
||
return (
|
||
<>
|
||
<div className="flex flex-col sm:flex-row sm:flex-wrap sm:items-center sm:justify-between gap-3 mb-5">
|
||
<div className="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400 flex-wrap">
|
||
<span className="px-2.5 py-1 rounded-md bg-[#EDAE49]/10 text-[#EDAE49] font-medium">
|
||
{rows.filter((r) => r.ytd > 0).length} machines with costs
|
||
</span>
|
||
<span className="hidden md:inline">· sorted by {sort === "ytd" ? "total spend" : "code"}</span>
|
||
</div>
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<div className="flex items-center gap-1 p-0.5 rounded-lg bg-white dark:bg-white/5 border border-gray-200 dark:border-border">
|
||
<button
|
||
onClick={() => setView("cards")}
|
||
aria-label="Cards view"
|
||
className={`min-h-[44px] px-3 text-xs font-medium rounded-md transition flex items-center gap-1.5 ${
|
||
view === "cards"
|
||
? "bg-[#003D5B] text-white dark:bg-[#00798C]"
|
||
: "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white"
|
||
}`}
|
||
>
|
||
<Sparkles className="w-3.5 h-3.5" /> Cards
|
||
</button>
|
||
<button
|
||
onClick={() => setView("grid")}
|
||
aria-label="Grid view"
|
||
className={`min-h-[44px] px-3 text-xs font-medium rounded-md transition flex items-center gap-1.5 ${
|
||
view === "grid"
|
||
? "bg-[#003D5B] text-white dark:bg-[#00798C]"
|
||
: "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white"
|
||
}`}
|
||
>
|
||
<LayoutGrid className="w-3.5 h-3.5" /> Grid
|
||
</button>
|
||
</div>
|
||
<div className="hidden sm:flex items-center gap-1">
|
||
<span className="text-xs text-gray-400 dark:text-text-soft mr-1">Sort:</span>
|
||
{[
|
||
{ id: "ytd", label: "Total" },
|
||
{ id: "code", label: "Code" },
|
||
].map((s) => (
|
||
<button
|
||
key={s.id}
|
||
onClick={() => setSort(s.id as typeof sort)}
|
||
className={`min-h-[44px] px-2 text-xs font-medium rounded transition ${
|
||
sort === s.id ? "bg-[#EDAE49] text-white" : "text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-white/5"
|
||
}`}
|
||
>
|
||
{s.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div className="sm:hidden">
|
||
<label className="sr-only" htmlFor="running-sort">Sort by</label>
|
||
<select
|
||
id="running-sort"
|
||
value={sort}
|
||
onChange={(e) => setSort(e.target.value as typeof sort)}
|
||
className="min-h-[44px] px-3 text-xs rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-white/5"
|
||
>
|
||
<option value="ytd">Sort by Total</option>
|
||
<option value="code">Sort by Code</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{view === "cards" ? (
|
||
<CardsView rows={sorted} expanded={expanded} setExpanded={setExpanded} palette={palette} visibleMonths={visibleMonths} />
|
||
) : (
|
||
<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, visibleMonths,
|
||
}: {
|
||
rows: RunningRow[];
|
||
visibleMonths?: number;
|
||
expanded: string | null;
|
||
setExpanded: (id: string | null) => void;
|
||
palette: { income: string; profit: string; loss: string };
|
||
}) {
|
||
return (
|
||
<div className="space-y-2">
|
||
{rows.map((r) => {
|
||
const isOpen = expanded === r.id;
|
||
const hasData = r.ytd > 0;
|
||
return (
|
||
<div
|
||
key={r.id}
|
||
className="bg-white dark:bg-surface border border-gray-100 dark:border-border rounded-xl overflow-hidden shadow-sm hover:shadow-md transition"
|
||
>
|
||
<button
|
||
onClick={() => setExpanded(isOpen ? null : r.id)}
|
||
className="w-full flex items-center gap-4 p-4 text-left"
|
||
>
|
||
<span className="font-mono text-xs px-2 py-1 rounded-md bg-[#00798C]/10 text-[#00798C] font-semibold shrink-0">
|
||
{r.code}
|
||
</span>
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center gap-2 text-sm font-medium text-[#003D5B] dark:text-white">
|
||
<Wrench className="w-3.5 h-3.5 text-[#EDAE49] shrink-0" />
|
||
<span className="truncate">{r.name}</span>
|
||
</div>
|
||
</div>
|
||
<div className="hidden sm:block shrink-0">
|
||
<Sparkline data={r.months} color={palette.income} />
|
||
</div>
|
||
<div className="text-right shrink-0 w-32 hidden md:block">
|
||
<div className="text-xs text-gray-400 uppercase tracking-wider">YTD spend</div>
|
||
<div className="text-sm font-semibold tabular-nums text-[#003D5B] dark:text-white">
|
||
{hasData ? ZAR2(r.ytd) : "—"}
|
||
</div>
|
||
</div>
|
||
<div
|
||
className={`shrink-0 px-2.5 py-1.5 rounded-lg text-xs font-semibold ${
|
||
hasData ? "bg-[#EDAE49]/10 text-[#EDAE49] dark:bg-[#EDAE49]/20" : "bg-gray-100 text-gray-400 dark:bg-white/5"
|
||
}`}
|
||
>
|
||
{hasData ? `${r.months.filter((v) => v > 0).length} months` : "—"}
|
||
</div>
|
||
<ChevronRight
|
||
className={`w-4 h-4 text-gray-400 shrink-0 transition-transform ${isOpen ? "rotate-90" : ""}`}
|
||
/>
|
||
</button>
|
||
|
||
{/* What the vehicle's spend is MADE of, on the card itself. These four parts
|
||
are the whole of it, so they add up to the YTD figure above — and the
|
||
vehicles add up to the Overview. A vehicle with no spend shows nothing
|
||
here: "nothing recorded" is already on the card. */}
|
||
{hasData && (
|
||
<div className="px-4 pb-3 -mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
|
||
{KINDS.map((k, index) => (
|
||
<span key={k} className="whitespace-nowrap">
|
||
<span className={r.byKindYtd[k] > 0 ? "text-gray-400 dark:text-text-soft" : "text-gray-300 dark:text-gray-600"}>{COST_KIND_LABELS[k]}</span>{" "}
|
||
<span className={r.byKindYtd[k] > 0 ? "font-medium tabular-nums text-gray-600 dark:text-gray-300" : "tabular-nums text-gray-300 dark:text-gray-600"}>{ZAR2(r.byKindYtd[k])}</span>
|
||
{index < KINDS.length - 1 && <span className="ml-3 text-gray-300 dark:text-gray-600">·</span>}
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{isOpen && (
|
||
<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-xs">
|
||
{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-xs 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"}`}>
|
||
{v > 0 ? ZAR2(v) : "—"}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
{/* The four kinds, and the total they add up to. This is the check: the
|
||
parts ARE the spend, so the parts and the total can never disagree. */}
|
||
<div className="mt-4 grid grid-cols-2 gap-x-4 gap-y-3 border-t border-gray-100 pt-3 text-xs dark:border-border sm:grid-cols-5">
|
||
{KINDS.map((k) => (
|
||
<div key={k}>
|
||
<div className="text-gray-400 uppercase tracking-wider text-xs">{COST_KIND_LABELS[k]}</div>
|
||
<div className="font-semibold tabular-nums text-[#003D5B] dark:text-white">{ZAR2(r.byKindYtd[k])}</div>
|
||
</div>
|
||
))}
|
||
<div>
|
||
<div className="text-gray-400 uppercase tracking-wider text-xs">YTD total</div>
|
||
<div className="font-semibold tabular-nums text-[#EDAE49]">{ZAR(r.ytd)}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function GridView({
|
||
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;
|
||
}) {
|
||
// Shown only when the table actually overflows — a permanent fade on a table that
|
||
// cannot scroll tells the reader to drag a page with nowhere to go.
|
||
const scroller = useRef<HTMLDivElement>(null);
|
||
const [overflows, setOverflows] = useState(false);
|
||
useEffect(() => {
|
||
const el = scroller.current;
|
||
if (!el) return;
|
||
const check = () => setOverflows(el.scrollWidth > el.clientWidth + 1);
|
||
check();
|
||
const ro = new ResizeObserver(check);
|
||
ro.observe(el);
|
||
return () => ro.disconnect();
|
||
}, [rows.length]);
|
||
|
||
return (
|
||
<div className="bg-white dark:bg-surface rounded-2xl border border-gray-100 dark:border-border shadow-sm overflow-hidden">
|
||
{/* `relative` + the conditional fade give the right-edge affordance a wide table
|
||
needs on a phone — but only when there is actually something to scroll to. */}
|
||
<div className="relative">
|
||
<div ref={scroller} className="overflow-x-auto" style={{ WebkitOverflowScrolling: "touch" }}>
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<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.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>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{rows.map((r) => (
|
||
<tr key={r.id} className="border-b border-gray-50 dark:border-border hover:bg-gray-50/40 dark:hover:bg-white/[0.02]">
|
||
<td className="py-2.5 px-6 sticky left-0 bg-white dark:bg-surface">
|
||
<span className="font-mono text-xs px-2 py-1 rounded-md bg-[#00798C]/10 text-[#00798C] font-semibold">
|
||
{r.code}
|
||
</span>
|
||
</td>
|
||
<td className="py-2.5 px-3 font-medium text-[#003D5B] dark:text-white whitespace-nowrap">
|
||
<div className="flex items-center gap-2">
|
||
<Wrench className="w-3 h-3 text-[#EDAE49]" />
|
||
{r.name}
|
||
</div>
|
||
</td>
|
||
{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>
|
||
))}
|
||
<td className={`py-2.5 px-6 text-right tabular-nums font-semibold whitespace-nowrap ${r.ytd > 0 ? "text-[#EDAE49]" : "text-gray-300 dark:text-text-soft"}`}>
|
||
{r.ytd > 0 ? ZAR(r.ytd) : "—"}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
{/* The four kinds, then the total they add up to. The fleet's parts are
|
||
visible above its total, so "do the costs add up?" is answered on the
|
||
screen instead of taken on trust. */}
|
||
{byKind && KINDS.map((k) => {
|
||
const cells = byKind[k].slice(0, visibleMonths ?? 12);
|
||
const kindTotal = cells.reduce((s, v) => s + v, 0);
|
||
return (
|
||
<tr key={k} className="border-t border-gray-50 dark:border-border">
|
||
<td colSpan={2} className="py-2 px-6 text-right text-xs text-gray-500 dark:text-gray-400">
|
||
{COST_KIND_LABELS[k]}
|
||
</td>
|
||
{cells.map((v, i) => (
|
||
<td key={i} className="py-2 px-2 text-right tabular-nums whitespace-nowrap text-gray-500 dark:text-gray-400">
|
||
{v > 0 ? ZAR(v) : "—"}
|
||
</td>
|
||
))}
|
||
<td className="py-2 px-6 text-right tabular-nums whitespace-nowrap text-gray-500 dark:text-gray-400">
|
||
{kindTotal > 0 ? ZAR(kindTotal) : "—"}
|
||
</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 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.slice(0, visibleMonths ?? 12).map((v, i) => (
|
||
<td
|
||
key={i}
|
||
title={byKind ? kindTitle(byKind, i) : undefined}
|
||
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 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>
|
||
</table>
|
||
</div>
|
||
{overflows && <ScrollFade />}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|