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>
277 lines
12 KiB
TypeScript
277 lines
12 KiB
TypeScript
"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>
|
||
);
|
||
}
|