mega-plant/components/DashboardCharts.tsx
Mega Plant 7194e78079 Round 3 (Parts A-B): charts fixes + expenses/overheads cleanup
- A.1: fixed_expenses per month now threaded through to chart bucket
- A.2: null = no data (forecast), 0 = real plotted zero — distinguishable
- A.3: x-axis clipped to last actual month (IncomeAreaChart + IncomeBarChart + MachineLineChart)
- A.4: chart hues as CSS vars (--color-chart-income/fixed/running) — deeper steps of same hues, validated for CVD
- A.5: chart-type toggle (Lines / Cost stack / Profit) + localStorage persistence + dynamic legend
- A.6: profit on charts page comes from computeProfit()
- B.1: deleted /expenses/plant-overheads, /expenses/insurance, /expenses/tracking (all empty); added /expenses/vehicles; ExpenseSectionPage now has an empty state
- B.2: captured R 157,200 Personnel block, R 100,000 Maintenance, R 10,000 Petty Cash; roll-up reconciles to R 675,162.93 (= sum of non-Plant non-Santam rows; workbook grand total R 1,110,486.87 is internally off by R 14,477.56 vs its own rows)
- B.3: seed flags incl<excl, VAT>20%, broken VAT math — does NOT silently correct; 4 anomalies reported on Charl-CCS + Buildsmart
- B.4: noted that workbook Vehicle Costs subtotal skips Santam (app R 9,673.87 is correct; workbook is wrong)
- B.2 reconciliation assertion added to verify-cost-reconciliation.mjs
- Sidebar updated: Vehicles added, Insurance/Tracking/Plant-Overheads removed
2026-09-12 11:24:18 +02:00

99 lines
4.7 KiB
TypeScript

"use client";
import {
AreaChart, Area, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip,
ResponsiveContainer, PieChart, Pie, Cell,
} from "recharts";
import { ZAR } from "@/lib/utils";
const pieColors = ["#30638E", "#00798C", "#EDAE49", "#003D5B", "#D1495B", "#EDAE49", "#00798C"];
// Read CSS vars for chart hues (Part A.4)
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 IncomeAreaChart({ data }: { data: { month: string; income: number; fixed: number; running: number; isForecast: boolean }[] }) {
// Part A.3 — clip to last actual month. Past months stay out so the chart doesn't flatline.
const lastActualIdx = data.findLastIndex((m) => !m.isForecast && (m.income || m.fixed || m.running));
const clipped = lastActualIdx >= 0 ? data.slice(0, lastActualIdx + 1) : data;
const INC = readVar("--color-chart-income", "#0090A6");
const FIX = readVar("--color-chart-fixed", "#BE851F");
const RUN = readVar("--color-chart-running", "#C13B4D");
return (
<div className="h-72">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={clipped} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="gIncome" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={INC} stopOpacity={0.4} />
<stop offset="100%" stopColor={INC} stopOpacity={0.02} />
</linearGradient>
<linearGradient id="gFixed" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={FIX} stopOpacity={0.35} />
<stop offset="100%" stopColor={FIX} stopOpacity={0.02} />
</linearGradient>
<linearGradient id="gRunning" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={RUN} stopOpacity={0.3} />
<stop offset="100%" stopColor={RUN} stopOpacity={0.02} />
</linearGradient>
</defs>
<CartesianGrid stroke="#f3f4f6" strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="month" stroke="#9ca3af" tickLine={false} axisLine={false} />
<YAxis stroke="#9ca3af" tickLine={false} axisLine={false} tickFormatter={(v) => `${(v / 1000).toFixed(0)}K`} />
<Tooltip formatter={(v: number) => ZAR(v)} />
<Area type="monotone" dataKey="income" stroke={INC} strokeWidth={2.5} fill="url(#gIncome)" />
<Area type="monotone" dataKey="fixed" stroke={FIX} strokeWidth={2.5} fill="url(#gFixed)" />
<Area type="monotone" dataKey="running" stroke={RUN} strokeWidth={2.5} fill="url(#gRunning)" />
</AreaChart>
</ResponsiveContainer>
</div>
);
}
export function IncomeBarChart({ data }: { data: { month: string; income: number }[] }) {
// Part A.3 — clip to last actual month. The IncomeAreaChart does the same; this matches.
const lastActualIdx = data.findLastIndex((m) => m.income > 0);
const clipped = lastActualIdx >= 0 ? data.slice(0, lastActualIdx + 1) : data;
const INC = readVar("--color-chart-income", "#0090A6");
return (
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={clipped} margin={{ top: 5, right: 10, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="gBar" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={INC} />
<stop offset="100%" stopColor={INC} stopOpacity={0.5} />
</linearGradient>
</defs>
<CartesianGrid stroke="#f3f4f6" strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="month" stroke="#9ca3af" tickLine={false} axisLine={false} />
<YAxis stroke="#9ca3af" tickLine={false} axisLine={false} tickFormatter={(v) => `${(v / 1000).toFixed(0)}K`} />
<Tooltip formatter={(v: number) => ZAR(v)} cursor={{ fill: "#f9fafb" }} />
<Bar dataKey="income" fill="url(#gBar)" radius={[8, 8, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
);
}
export function OverheadDonut({ data }: { data: { name: string; value: number }[] }) {
return (
<div className="h-52">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie data={data} dataKey="value" nameKey="name" innerRadius={50} outerRadius={80} paddingAngle={2}>
{data.map((_, i) => (
<Cell key={i} fill={pieColors[i % pieColors.length]} />
))}
</Pie>
<Tooltip formatter={(v: number) => ZAR(v)} />
</PieChart>
</ResponsiveContainer>
</div>
);
}
export const PIE_COLORS = pieColors;