- 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
99 lines
4.7 KiB
TypeScript
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;
|