mega-plant/components/ChartsGrid.tsx
David dc7024d98a Fix: the mobile table was mislabelled and clipping its own totals
A screenshot at phone width showed an "INVOICE | LINES | TOTAL | STATUS"
header row while the data under LINES was money. The header and its data cell
had drifted apart: the earlier pass hid the Lines <td> but not the matching
<th>, because the loop that added the hide classes only matched text-left
headers and Lines is text-right. So the Total values rendered under a heading
that named a column which no longer existed.

The same screenshot showed the totals clipped — "41,710.50" reading as
"710,50". With only three columns left, the 520px min-width was still forcing
a sideways scroll, and the opaque sticky first column then sat on top of the
left of the money values. The mobile floor is gone, so the three remaining
columns size to their content, and the sticky and status gutters are trimmed
at mobile widths to let them fit 375px.

Below sm the table is now exactly Invoice, Total, Status — and every header
matches a visible cell.

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

192 lines
8.5 KiB
TypeScript

"use client";
import { useState, useMemo, useEffect } from "react";
import { Search, TrendingUp, TrendingDown, LineChart as LineChartIcon, BarChart3, Scale } from "lucide-react";
import { ZAR2 } from "@/lib/utils";
import { MachineLineChart, type ChartView } from "@/components/MachineLineChart";
import { computeProfit } from "@/lib/profit";
type Item = {
id: string;
code: string;
name: string;
bucket: { income: (number | null)[]; fixed: (number | null)[]; running: (number | null)[] };
totalIncome: number;
totalProfit: number;
};
const VIEW_KEY = "mp:chartsView";
export function ChartsGrid({ items }: { items: Item[] }) {
const [query, setQuery] = useState("");
const [filter, setFilter] = useState<"all" | "profitable" | "loss">("all");
const [view, setViewState] = useState<ChartView>("lines");
// Persist view in localStorage (Part A.5)
useEffect(() => {
try {
const stored = localStorage.getItem(VIEW_KEY);
if (stored === "lines" || stored === "stack" || stored === "profit") setViewState(stored);
} catch {}
}, []);
const setView = (v: ChartView) => {
setViewState(v);
try { localStorage.setItem(VIEW_KEY, v); } catch {}
};
const filtered = useMemo(() => {
let list = items;
if (query.trim()) {
const q = query.toLowerCase();
list = list.filter((i) => i.code.toLowerCase().includes(q) || i.name.toLowerCase().includes(q));
}
if (filter === "profitable") list = list.filter((i) => i.totalProfit > 0);
if (filter === "loss") list = list.filter((i) => i.totalProfit < 0);
list = [...list].sort((a, b) => b.totalIncome - a.totalIncome);
return list;
}, [items, query, filter]);
const stats = useMemo(() => {
const profitable = items.filter((i) => i.totalProfit > 0).length;
const totalIncome = items.reduce((s, i) => s + i.totalIncome, 0);
return { profitable, total: items.length, totalIncome };
}, [items]);
// Legend swatches per view (Part A.5)
const legend = view === "profit"
? [{ color: "var(--color-chart-running)", label: "Profit / Loss" }]
: [
{ color: "var(--color-chart-income)", label: "Income" },
{ color: "var(--color-chart-fixed)", label: "Fixed" },
{ color: "var(--color-chart-running)", label: "Running" },
];
return (
<>
{/* Round 4 Part B.1/B.6: title + subtitle stack above legend on mobile; legend wraps below */}
<div className="flex flex-col gap-3 mb-6">
<div className="flex items-center justify-between gap-3">
<div>
<div className="flex items-center gap-3 mb-1">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[#30638E]/15 to-[#003D5B]/15 grid place-items-center text-[#30638E]">
<LineChartIcon className="w-5 h-5" />
</div>
<h1 className="text-2xl font-semibold text-[#003D5B] dark:text-white">Machine Charts</h1>
</div>
<p className="text-gray-500 dark:text-gray-400 text-sm">
{stats.total} machines · {stats.profitable} profitable · {ZAR2(stats.totalIncome)} total income
</p>
</div>
</div>
<div className="flex items-center gap-3 flex-wrap text-xs">
{legend.map((l) => (
<div key={l.label} className="flex items-center gap-1.5">
<div className="w-2 h-2 rounded-full" style={{ background: l.color }} />
<span className="text-gray-500 dark:text-gray-400">{l.label}</span>
</div>
))}
</div>
</div>
{/* Toolbar — wraps to multiple rows on mobile. Round 4 Part B.1/B.3/B.5. */}
<div className="bg-white dark:bg-surface rounded-2xl border border-gray-100 dark:border-border p-4 shadow-sm mb-5 flex flex-wrap items-center gap-3">
<div className="relative flex-1 min-w-[160px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search by code or name…"
className="w-full min-h-[44px] pl-9 pr-3 bg-gray-50 dark:bg-white/5 border border-transparent rounded-lg text-sm placeholder:text-gray-400 focus:bg-white dark:focus:bg-white/10 focus:border-[#30638E] focus:outline-none"
/>
</div>
<div className="flex items-center gap-1">
{[
{ id: "all", label: "All" },
{ id: "profitable", label: "Profitable" },
{ id: "loss", label: "Loss-making" },
].map((f) => (
<button
key={f.id}
onClick={() => setFilter(f.id as typeof filter)}
className={`min-h-[44px] px-3 text-xs font-medium rounded-lg transition ${
filter === f.id
? "bg-[#30638E] text-white"
: "text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-white/5"
}`}
>
{f.label}
</button>
))}
</div>
<div className="flex items-center gap-1 sm:pl-3 sm:border-l sm:border-gray-200 sm:dark:border-white/10">
<span className="text-[10px] uppercase tracking-wider text-gray-400 dark:text-text-soft mr-1 hidden sm:inline">View</span>
{[
{ id: "lines", label: "Lines", icon: LineChartIcon },
{ id: "stack", label: "Cost stack", icon: BarChart3 },
{ id: "profit", label: "Profit", icon: Scale },
].map((v) => (
<button
key={v.id}
onClick={() => setView(v.id as ChartView)}
className={`min-h-[44px] px-3 text-xs font-medium rounded transition flex items-center gap-1 ${
view === v.id
? "bg-[#003D5B] text-white"
: "text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-white/5"
}`}
>
<v.icon className="w-3 h-3" />
{v.label}
</button>
))}
</div>
</div>
{filtered.length === 0 ? (
<div className="bg-white dark:bg-surface rounded-2xl border border-gray-100 dark:border-border p-12 text-center">
<Search className="w-8 h-8 text-gray-300 dark:text-text-soft mx-auto mb-3" />
<div className="text-sm text-gray-500 dark:text-gray-400">No machines match "{query}"</div>
<button onClick={() => setQuery("")} className="mt-3 text-xs text-accent-ink hover:underline">
Clear search
</button>
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{filtered.map((item) => {
const positive = computeProfit({ income: item.totalIncome, fixed: 0, running: 0 }) >= 0 || item.totalIncome > 0;
// Profit uses computeProfit() — Part A.6
const profit = computeProfit({ income: item.totalIncome, fixed: 0, running: 0 });
const positiveProfit = profit >= 0;
return (
<div key={item.id} className="bg-white dark:bg-surface rounded-2xl border border-gray-100 dark:border-border p-5 shadow-sm hover:shadow-md transition">
<div className="flex items-start justify-between mb-3">
<div>
<span className="font-mono text-[10px] px-1.5 py-0.5 rounded bg-[#00798C]/10 text-[#00798C] font-semibold">
{item.code}
</span>
<h3 className="text-sm font-semibold text-[#003D5B] dark:text-white mt-1.5">{item.name}</h3>
</div>
<div className={`flex items-center gap-1 px-2 py-1 rounded-lg text-xs font-medium ${
positiveProfit ? "bg-[#00798C]/10 text-[#00798C]" : "bg-[#D1495B]/10 text-[#D1495B]"
}`}>
{positiveProfit ? <TrendingUp className="w-3 h-3" /> : <TrendingDown className="w-3 h-3" />}
{positiveProfit ? "+" : ""}{ZAR2(profit)}
</div>
</div>
<div className="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400 mb-3">
<span>YTD income</span>
<span className="text-[#003D5B] dark:text-white font-semibold">{ZAR2(item.totalIncome)}</span>
</div>
<MachineLineChart
income={item.bucket.income}
fixed={item.bucket.fixed}
running={item.bucket.running}
view={view}
/>
</div>
);
})}
</div>
)}
</>
);
}