mega-plant/components/MachineLineChart.tsx
Mega Plant 0516220110 Round 4: regressions fixed + mobile standard
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.
2026-09-12 12:05:43 +02:00

114 lines
4.9 KiB
TypeScript

"use client";
import {
ComposedChart, Line, LineChart, Bar, AreaChart, Area, Cell,
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
} from "recharts";
import { MONTH_NAMES } from "@/lib/data";
import { computeProfit } from "@/lib/profit";
export type ChartView = "lines" | "stack" | "profit";
type Props = {
income: (number | null)[];
fixed: (number | null)[];
running: (number | null)[];
view?: ChartView;
incomeColor?: string;
fixedColor?: string;
runningColor?: string;
profitColor?: string;
};
function readVar(name: string, fallback: string): string {
if (typeof window === "undefined") return fallback;
const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return v || fallback;
}
export function MachineLineChart({
income, fixed, running,
view = "lines",
incomeColor, fixedColor, runningColor, profitColor,
}: Props) {
const INC = incomeColor ?? readVar("--color-chart-income", "#0090A6");
const FIX = fixedColor ?? readVar("--color-chart-fixed", "#BE851F");
const RUN = runningColor ?? readVar("--color-chart-running", "#C13B4D");
const PROF = profitColor ?? readVar("--color-chart-running", "#D1495B");
const lastActual = Math.max(
income.findLastIndex((v) => v != null),
fixed.findLastIndex((v) => v != null),
running.findLastIndex((v) => v != null),
);
const clipped = lastActual >= 0 ? lastActual + 1 : MONTH_NAMES.length;
const months = MONTH_NAMES.slice(0, clipped);
const data = months.map((m, i) => ({
month: m,
income: income[i] ?? null,
fixed: fixed[i] ?? null,
running: running[i] ?? null,
profit: computeProfit({ income: income[i] ?? 0, fixed: fixed[i] ?? 0, running: running[i] ?? 0 }),
}));
const fmt = (v: number) =>
new Intl.NumberFormat("en-ZA", { style: "currency", currency: "ZAR", maximumFractionDigits: 0, notation: "compact" }).format(v);
if (view === "profit") {
return (
<div className="h-44 sm:h-52">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={data} margin={{ top: 5, right: 10, left: 0, bottom: 0 }}>
<CartesianGrid stroke="#f3f4f6" strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="month" stroke="#9ca3af" tick={{ fontSize: 10 }} tickLine={false} axisLine={false} />
<YAxis stroke="#9ca3af" tick={{ fontSize: 10 }} tickLine={false} axisLine={false} tickFormatter={fmt} width={60} />
<Tooltip formatter={(v: number) => fmt(v)} />
<Bar dataKey="profit" radius={[4, 4, 0, 0]} fill={INC}>
{data.map((d, i) => (
<Cell key={i} fill={d.profit >= 0 ? INC : RUN} />
))}
</Bar>
</ComposedChart>
</ResponsiveContainer>
</div>
);
}
if (view === "stack") {
return (
<div className="h-44 sm:h-52">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={data} margin={{ top: 5, right: 10, left: 0, bottom: 0 }} stackOffset="sign">
<CartesianGrid stroke="#f3f4f6" strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="month" stroke="#9ca3af" tick={{ fontSize: 10 }} tickLine={false} axisLine={false} />
<YAxis stroke="#9ca3af" tick={{ fontSize: 10 }} tickLine={false} axisLine={false} tickFormatter={fmt} width={60} />
<Tooltip formatter={(v: number) => fmt(v)} />
<Bar dataKey="fixed" stackId="a" fill={FIX} radius={[0, 0, 0, 0]} />
<Bar dataKey="running" stackId="a" fill={RUN} radius={[4, 4, 0, 0]} barSize={20} />
<Line type="monotone" dataKey="income" stroke={INC} strokeWidth={2.5} dot={{ r: 3, fill: INC }} connectNulls={false} />
</ComposedChart>
</ResponsiveContainer>
</div>
);
}
// Round 4 Part A.5: default "Lines" view draws ACTUAL LINES (stroked, no fill).
// The chart's whole purpose is to match the workbook — the workbook draws lines.
return (
<div className="h-44 sm:h-52">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data} margin={{ top: 5, right: 10, left: 0, bottom: 0 }}>
<CartesianGrid stroke="#f3f4f6" strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="month" stroke="#9ca3af" tick={{ fontSize: 10 }} tickLine={false} axisLine={false} />
<YAxis stroke="#9ca3af" tick={{ fontSize: 10 }} tickLine={false} axisLine={false} tickFormatter={fmt} width={60} />
<Tooltip formatter={(v: number) => fmt(v)} />
<Line type="monotone" dataKey="income" stroke={INC} strokeWidth={2.5} dot={{ r: 3, fill: INC }} connectNulls={false} />
<Line type="monotone" dataKey="fixed" stroke={FIX} strokeWidth={2.5} dot={{ r: 3, fill: FIX }} connectNulls={false} />
<Line type="monotone" dataKey="running" stroke={RUN} strokeWidth={2.5} dot={{ r: 3, fill: RUN }} connectNulls={false} />
</LineChart>
</ResponsiveContainer>
</div>
);
}