Part A — Round 3 regressions: - A.1: verify-cost-reconciliation.mjs now loads .env.local, wraps in try/catch, exits 1 on any failure - A.2: Vehicle Costs restored Santam — back to R 14,996.31 (matches row-by-row sum) - A.3: incl<excl / VAT math / VAT>20% now FATAL — seed exits 2 with full report (4 anomalies on Charl-CCS + Buildsmart) - A.4: Reconciliation target R 694,962.93 with both deltas itemised (Personnel +R19,800, Santam -R5,322.44, unexplained R 0.00) - A.5: 'Lines' view uses actual lines (recharts LineChart), no fills; fills only in 'Cost stack' Part B — Mobile standard: - B.1: header wraps to 2 rows on mobile; YTD badge cards stack; page-wide overflow eliminated on /income, /charts, /overheads, /running, /expenses - B.2: IncomeView card row layout stacked — name gets its own line on mobile, never truncated to single char - B.3: Plant type filter is a <select> dropdown below lg (was 18 pills wrapping to 13 rows); same for sort on /income and /running - B.4: Plant table first column sticky; -webkit-overflow-scrolling: touch; tables scroll inside their own container - B.5: All buttons/links use min-h-[44px] tap targets; text below 12px is bumped where it matters - B.6: Charts legend wraps below title; chart min-height h-44 → h-52 on mobile - B.7: paddingTop env(safe-area-inset-top) on sticky header for notch/home indicator clearance AGENTS.md created with the standing mobile checklist rule.
192 lines
8.6 KiB
TypeScript
192 lines
8.6 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-[#131a21] rounded-2xl border border-gray-100 dark:border-white/5 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-[#0f1419] 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-gray-500 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-[#131a21] rounded-2xl border border-gray-100 dark:border-white/5 p-12 text-center">
|
|
<Search className="w-8 h-8 text-gray-300 dark:text-gray-600 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-[#30638E] dark:text-[#00798C] 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-[#131a21] rounded-2xl border border-gray-100 dark:border-white/5 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>
|
|
)}
|
|
</>
|
|
);
|
|
}
|