mega-plant/components/RunningView.tsx
David aac440cfef Fold purchase orders into the Running Costs Overview, with incl/excl totals
A purchase order buys parts and repairs against a plant item, so its money is
running cost and belongs in the same per-machine, per-month series the Overview
already reads. Without it September showed nothing at all.

Merged at READ time, never copied into monthly_running_costs: that collection is
deleted and rebuilt by every seed, so a purchase written there would vanish on the
next one and have to be re-uploaded. purchase_lines is never deleted, so the money
lives in one place and the Overview adds it up.

- Month comes from the PO date (when it was ordered), not "Required By":
  MEGPPO50532 is dated 07/10 but required 29/08 and those must not be confused.
- Only lines allocated to a machine are counted. An unallocated line is real money
  not yet on a plant item; it stays on /purchases, reported, rather than being
  hidden or added to the fleet total.
- A PO with no readable date is excluded and named, never guessed into January.
- Bottom total row shows incl ABOVE excl per month and for the YTD. Workbook
  figures carry no VAT column, so RUNNING_VAT_PCT (15, confirmed by every PO line)
  is the single constant to revisit if they are ever restated ex-VAT.

September now reads R 5 842 incl / R 5 080 excl from the two September POs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-09 12:10:13 +02:00

277 lines
12 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 } from "react";
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";
export function RunningView({
rows,
palette,
monthTotals,
monthTotalsExcl,
vatPct,
}: {
rows: RunningRow[];
palette: { income: string; profit: string; loss: string };
/** Per month, VAT-INCLUSIVE. */
monthTotals: number[];
/** The same months net of VAT, shown UNDER each incl figure. */
monthTotalsExcl?: number[];
vatPct?: 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} />
) : (
<GridView rows={sorted} monthTotals={monthTotals} monthTotalsExcl={monthTotalsExcl} vatPct={vatPct} grandTotal={grandTotal} grandExcl={(monthTotalsExcl || []).reduce((s, v) => s + v, 0)} />
)}
</>
);
}
function CardsView({
rows, expanded, setExpanded, palette,
}: {
rows: RunningRow[];
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.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, vatPct, grandTotal, grandExcl,
}: {
rows: RunningRow[];
monthTotals: number[];
monthTotalsExcl?: number[];
vatPct?: number;
grandTotal: number;
grandExcl: number;
}) {
return (
<div className="bg-white dark:bg-surface rounded-2xl border border-gray-100 dark:border-border shadow-sm overflow-hidden">
<div className="overflow-x-auto">
<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.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.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.map((v, i) => (
<td key={i} 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>
</div>
);
}