mega-plant/components/RunningView.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

321 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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 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])}`
);
}
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-[10px] 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>
{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-[11px]">
{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"}`}>
{v > 0 ? ZAR2(v) : "—"}
</div>
</div>
))}
</div>
<div className="mt-4 pt-3 border-t border-gray-100 dark:border-border text-xs">
<span className="text-gray-400 uppercase tracking-wider text-[10px]">YTD total</span>
<span className="ml-2 font-semibold tabular-nums text-[#EDAE49]">{ZAR(r.ytd)}</span>
</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>
))}
{/* 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>
);
}