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>
321 lines
15 KiB
TypeScript
321 lines
15 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 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>
|
||
);
|
||
}
|