The field is `assets.is_active`. All 7 inactive machines have no income at all — the three generators, two porta camps, AEB Plant Versekering and the Diesel Truck — so on the income list they were a flat row of zeros and nothing else, reading as "idle" when they are actually out of the fleet. They are now hidden by default. The rows are not dropped from the data: the filter lives in the view, so Active / All / Inactive is a client-side toggle with no refetch, and each shows its count so a filter that silently hides rows is obvious. A machine hidden by the filter also collapses if it was expanded. The YTD income and profit tiles moved into the view so they sum only what is visible — otherwise the headline would report the whole fleet while the list below it showed a subset. /charts is unchanged: it passes no filter and still sees everything. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
254 lines
11 KiB
TypeScript
254 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useId, useState } from "react";
|
|
import {
|
|
ComposedChart, Area, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip,
|
|
ResponsiveContainer, PieChart, Pie, Cell,
|
|
} from "recharts";
|
|
import type { TooltipProps } from "recharts";
|
|
import { ZAR } from "@/lib/utils";
|
|
import { buildProfitSeries } from "@/lib/chart-profit";
|
|
|
|
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;
|
|
}
|
|
|
|
// Dark mode is a class on <html>, which React cannot see. Watching it keeps the
|
|
// tooltip and the bar cursor correct the instant the theme is toggled.
|
|
function useIsDark(): boolean {
|
|
const [dark, setDark] = useState(false);
|
|
useEffect(() => {
|
|
const root = document.documentElement;
|
|
const update = () => setDark(root.classList.contains("dark"));
|
|
update();
|
|
const mo = new MutationObserver(update);
|
|
mo.observe(root, { attributes: true, attributeFilter: ["class"] });
|
|
return () => mo.disconnect();
|
|
}, []);
|
|
return dark;
|
|
}
|
|
|
|
// One tooltip for every chart on the dashboard.
|
|
//
|
|
// Recharts' DEFAULT tooltip paints each row's text in the SERIES COLOUR. The pie's
|
|
// slice colours include #003D5B and #30638E, so in dark mode the tooltip was drawing
|
|
// near-black text on the near-black tooltip background that globals.css sets — legible
|
|
// on nothing. globals.css can only restyle the container; the per-row colour is
|
|
// inline, so the row has to be rendered here instead.
|
|
//
|
|
// It keeps the `recharts-default-tooltip` class, so globals.css still styles the box.
|
|
type DashTooltipProps = TooltipProps<number, string> & {
|
|
/** Keys that only exist to carry a gradient, never shown as their own row. */
|
|
internal?: RegExp;
|
|
/** Profit is computed from the payload rather than drawn as a series. */
|
|
profitRow?: { name: string; value: number; up: string; down: string } | null;
|
|
};
|
|
|
|
function DashTooltip({ active, payload, label, internal, profitRow }: DashTooltipProps) {
|
|
if (!active || !payload?.length) return null;
|
|
|
|
const rows: { name: string; text: string; color: string }[] = [];
|
|
for (const p of payload) {
|
|
const key = String(p.dataKey ?? "");
|
|
if (internal?.test(key)) continue;
|
|
if (key === "profit") continue;
|
|
if (p.value == null) continue;
|
|
rows.push({ name: p.name ?? key, text: ZAR(Number(p.value)), color: p.color || p.stroke || "#9ca3af" });
|
|
}
|
|
if (profitRow) {
|
|
rows.push({
|
|
name: profitRow.name,
|
|
text: ZAR(profitRow.value),
|
|
color: profitRow.value >= 0 ? profitRow.up : profitRow.down,
|
|
});
|
|
}
|
|
if (!rows.length) return null;
|
|
|
|
return (
|
|
<div className="recharts-default-tooltip">
|
|
{label != null && <div className="recharts-tooltip-label">{label}</div>}
|
|
<ul className="recharts-tooltip-items" style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
|
{rows.map((r, i) => (
|
|
<li
|
|
key={`${r.name}-${i}`}
|
|
className="recharts-tooltip-item"
|
|
style={{ display: "flex", alignItems: "center", gap: 8, padding: "1px 0" }}
|
|
>
|
|
<span
|
|
style={{
|
|
width: 8, height: 8, borderRadius: 2, flexShrink: 0,
|
|
background: r.color,
|
|
}}
|
|
/>
|
|
<span className="recharts-tooltip-item-name" style={{ color: "inherit", opacity: 0.85 }}>
|
|
{r.name}
|
|
</span>
|
|
<span
|
|
className="recharts-tooltip-item-value"
|
|
style={{ marginLeft: "auto", fontWeight: 600, color: "inherit" }}
|
|
>
|
|
{r.text}
|
|
</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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", "#8B5CF6");
|
|
const UP = readVar("--color-chart-profit", "#7A9E06");
|
|
const DOWN = readVar("--color-chart-loss", "#C13B4D");
|
|
const dark = useIsDark();
|
|
const uid = useId().replace(/[^a-zA-Z0-9]/g, "");
|
|
|
|
// Income less fixed less running, per the client's ruling for this chart.
|
|
// A month with no income at all gets null rather than 0: 0 would plot a confident
|
|
// green "no loss" point for a month that was never billed.
|
|
const profit = clipped.map((m) => (m.income ? m.income - m.fixed - m.running : null));
|
|
|
|
const chartData = clipped.map((m, i) => ({ ...m, profit: profit[i] })) as (
|
|
Record<string, unknown> & { month: string; profit: number | null }
|
|
)[];
|
|
|
|
const { series, gradients } = buildProfitSeries({
|
|
profit,
|
|
upColor: UP,
|
|
downColor: DOWN,
|
|
idPrefix: `${uid}-dash`,
|
|
mutate: (key, keep) => {
|
|
const on = new Set(keep);
|
|
for (let i = 0; i < chartData.length; i++) chartData[i][key] = on.has(i) ? profit[i] : null;
|
|
},
|
|
});
|
|
|
|
// One total row for the tooltip rather than a per-variant row per run, which would
|
|
// otherwise list "profitRun0" and "profitCross0" as if they were separate series.
|
|
const hasProfit = profit.some((v) => v != null);
|
|
const profitTotal = profit.reduce<number>((s, v) => s + (v ?? 0), 0);
|
|
|
|
return (
|
|
<div className="h-72">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
{/* ComposedChart, not AreaChart: Recharts only parses the child types each
|
|
wrapper declares, and AreaChart does not accept <Line>. A profit Line
|
|
dropped into an AreaChart vanishes without an error. */}
|
|
<ComposedChart data={chartData} 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>
|
|
{gradients.map((g) => (
|
|
<linearGradient key={g.id} id={g.id} x1="0" y1="0" x2="1" y2="0">
|
|
<stop offset="0%" stopColor={g.from} />
|
|
<stop offset="100%" stopColor={g.to} />
|
|
</linearGradient>
|
|
))}
|
|
</defs>
|
|
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
|
<XAxis dataKey="month" tickLine={false} axisLine={false} />
|
|
<YAxis tickLine={false} axisLine={false} tickFormatter={(v) => `${(v / 1000).toFixed(0)}K`} />
|
|
<Tooltip
|
|
cursor={{ fill: dark ? "rgba(255,255,255,0.06)" : "rgba(0,0,0,0.03)" }}
|
|
content={
|
|
<DashTooltip
|
|
internal={/^(profitRun|profitCross)/}
|
|
profitRow={hasProfit ? { name: "Profit", value: profitTotal, up: UP, down: DOWN } : null}
|
|
/>
|
|
}
|
|
/>
|
|
<Area type="monotone" dataKey="income" name="Income" stroke={INC} strokeWidth={2.5} fill="url(#gIncome)" />
|
|
<Area type="monotone" dataKey="fixed" name="Fixed" stroke={FIX} strokeWidth={2.5} fill="url(#gFixed)" />
|
|
<Area type="monotone" dataKey="running" name="Running" stroke={RUN} strokeWidth={2.5} fill="url(#gRunning)" />
|
|
{/* Profit must be DIRECT children of the chart — Recharts parses only known
|
|
child types, so wrapping these in a <g> makes the whole line disappear. */}
|
|
{series.map((s) => (
|
|
<Line
|
|
key={s.key}
|
|
type="monotone"
|
|
dataKey={s.key}
|
|
stroke={s.stroke}
|
|
strokeWidth={2.5}
|
|
dot={false}
|
|
connectNulls={false}
|
|
isAnimationActive={false}
|
|
legendType="none"
|
|
/>
|
|
))}
|
|
</ComposedChart>
|
|
</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");
|
|
const dark = useIsDark();
|
|
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 strokeDasharray="3 3" vertical={false} />
|
|
<XAxis dataKey="month" tickLine={false} axisLine={false} />
|
|
<YAxis tickLine={false} axisLine={false} tickFormatter={(v) => `${(v / 1000).toFixed(0)}K`} />
|
|
{/* The cursor is the band that follows the pointer across the bars. It was a
|
|
hard-coded #f9fafb, so scrolling the bars threw a near-white stripe across
|
|
a dark card. */}
|
|
<Tooltip
|
|
cursor={{ fill: dark ? "rgba(255,255,255,0.06)" : "rgba(0,0,0,0.03)" }}
|
|
content={<DashTooltip />}
|
|
/>
|
|
<Bar dataKey="income" name="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 content={<DashTooltip />} />
|
|
</PieChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export const PIE_COLORS = pieColors;
|