diff --git a/app/income/page.tsx b/app/income/page.tsx index 453c7a9..8728c83 100644 --- a/app/income/page.tsx +++ b/app/income/page.tsx @@ -1,5 +1,4 @@ import { getAssets, getActualMonthlyIncome, getRunningCosts, getInvoiceLines } from "@/lib/data"; -import { ZAR } from "@/lib/utils"; import { TrendingUp } from "lucide-react"; import { IncomeView } from "@/components/IncomeView"; import { buildIncomeRows } from "@/lib/rows"; @@ -21,9 +20,6 @@ export default async function IncomePage() { // show how much of it is invoice income and how much of that is VAT. const rows = buildIncomeRows(assets, incomeActual, running, {}, invoiceLines); - const totalIncome = rows.reduce((s, m) => s + m.income, 0); - const totalProfit = rows.reduce((s, m) => s + m.ytdProfit, 0); - return (
{/* Stacks until lg, not sm. As a side-by-side row the title block took 371px @@ -41,23 +37,13 @@ export default async function IncomePage() {

Project income per machine per month · 2026

- {/* grid, not flex-wrap: a wrapping flex row sizes tiles by CONTENT, which - is the ragged-left-edge trap AGENTS.md records for /income/invoices. - Full-width stacked on mobile, two-up from sm — same shape the dashboard - cards already use there. */} -
-
-
YTD Income
-
{ZAR(totalIncome)}
-
-
= 0 ? "from-[#EDAE49] to-[#D1495B]" : "from-[#D1495B] to-[#003D5B]"} rounded-2xl px-5 py-3 text-white shadow-lg`}> -
YTD Profit
-
{ZAR(totalProfit)}
-
-
+ {/* The YTD income/profit tiles live inside IncomeView now, so they follow the + active/inactive filter instead of reporting the whole fleet. */} + - - ); } diff --git a/app/page.tsx b/app/page.tsx index a3efe08..dc2b78c 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -106,10 +106,14 @@ export default async function DashboardPage() {

Income vs Fixed vs Running

2026 monthly trend · R

+ {/* The dots read the same tokens the chart reads. They were hard-coded, so + the Running dot was Brick Red while the Running line is actually the + project's purple — the legend was describing a colour not on screen. */}
- - - + + + +
@@ -171,7 +175,7 @@ function GradientCard({ style={{ width: `${Math.min(100, Math.max(0, pct))}%` }} /> -
+
{negative ? "Deficit" : "On target"} {pct}%
diff --git a/components/DashboardCharts.tsx b/components/DashboardCharts.tsx index b12aa37..beb04f6 100644 --- a/components/DashboardCharts.tsx +++ b/components/DashboardCharts.tsx @@ -1,10 +1,13 @@ "use client"; +import { useEffect, useId, useState } from "react"; import { - AreaChart, Area, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, + 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"]; @@ -15,6 +18,89 @@ function readVar(name: string, fallback: string): string { return v || fallback; } +// Dark mode is a class on , 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 & { + /** 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 ( +
+ {label != null &&
{label}
} +
    + {rows.map((r, i) => ( +
  • + + + {r.name} + + + {r.text} + +
  • + ))} +
+
+ ); +} + 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)); @@ -22,10 +108,43 @@ export function IncomeAreaChart({ data }: { data: { month: string; income: numbe 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 & { 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((s, v) => s + (v ?? 0), 0); + return (
- + {/* ComposedChart, not AreaChart: Recharts only parses the child types each + wrapper declares, and AreaChart does not accept . A profit Line + dropped into an AreaChart vanishes without an error. */} + @@ -39,15 +158,44 @@ export function IncomeAreaChart({ data }: { data: { month: string; income: numbe + {gradients.map((g) => ( + + + + + ))} - - - `${(v / 1000).toFixed(0)}K`} /> - ZAR(v)} /> - - - - + + + `${(v / 1000).toFixed(0)}K`} /> + + } + /> + + + + {/* Profit must be DIRECT children of the chart — Recharts parses only known + child types, so wrapping these in a makes the whole line disappear. */} + {series.map((s) => ( + + ))} +
); @@ -58,6 +206,7 @@ export function IncomeBarChart({ data }: { data: { month: string; income: number 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 (
@@ -68,11 +217,17 @@ export function IncomeBarChart({ data }: { data: { month: string; income: number - - - `${(v / 1000).toFixed(0)}K`} /> - ZAR(v)} cursor={{ fill: "#f9fafb" }} /> - + + + `${(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. */} + } + /> +
@@ -89,7 +244,7 @@ export function OverheadDonut({ data }: { data: { name: string; value: number }[ ))} - ZAR(v)} /> + } />
diff --git a/components/IncomeView.tsx b/components/IncomeView.tsx index 9f9a9a7..d5d2008 100644 --- a/components/IncomeView.tsx +++ b/components/IncomeView.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useMemo } from "react"; +import { useState, useMemo, useEffect } from "react"; import { TrendingUp, TrendingDown, LayoutGrid, Sparkles, ChevronRight } from "lucide-react"; import { Sparkline } from "@/components/Sparkline"; import { MONTH_NAMES } from "@/lib/data"; @@ -12,23 +12,43 @@ export function IncomeView({ rows, palette }: { rows: IncomeRow[]; palette: { in const [view, setView] = useState<"cards" | "grid">("cards"); const [sort, setSort] = useState<"profit" | "income">("profit"); const [expanded, setExpanded] = useState(null); + // Machines the client has stood down show nothing but a flat row of zeros — they are + // out of the fleet, not idle. Hidden by default, reachable behind the filter. + const [status, setStatus] = useState<"active" | "all" | "inactive">("active"); + + const visible = useMemo( + () => + rows.filter((r) => + status === "all" ? true : status === "active" ? r.isActive : !r.isActive, + ), + [rows, status], + ); + + useEffect(() => { + // A machine hidden by the filter must not stay stuck expanded. + if (expanded && !visible.some((r) => r.id === expanded)) setExpanded(null); + }, [visible, expanded]); + + const activeN = useMemo(() => rows.filter((r) => r.isActive).length, [rows]); + const inactiveN = rows.length - activeN; const sorted = useMemo(() => { - return [...rows].sort((a, b) => + return [...visible].sort((a, b) => sort === "profit" ? b.ytdProfit - a.ytdProfit : b.income - a.income ); - }, [rows, sort]); + }, [visible, sort]); const totals = useMemo(() => { - const totalIncome = rows.reduce((s, r) => s + r.income, 0); - const totalProfit = rows.reduce((s, r) => s + r.ytdProfit, 0); - const profitable = rows.filter((r) => r.ytdProfit > 0).length; + // Summed over what is VISIBLE, so the headline never disagrees with the list below it. + const totalIncome = visible.reduce((s, r) => s + r.income, 0); + const totalProfit = visible.reduce((s, r) => s + r.ytdProfit, 0); + const profitable = visible.filter((r) => r.ytdProfit > 0).length; // VAT already inside the booked figures — booked amounts are VAT-inclusive, so this // is part of the income total and is called out rather than hidden. - const invoiceVat = rows.reduce((s, r) => s + r.invoiceVat, 0); - const invoiceIncome = rows.reduce((s, r) => s + r.invoiceIncome, 0); + const invoiceVat = visible.reduce((s, r) => s + r.invoiceVat, 0); + const invoiceIncome = visible.reduce((s, r) => s + r.invoiceIncome, 0); return { totalIncome, totalProfit, profitable, invoiceVat, invoiceIncome }; - }, [rows]); + }, [visible]); return ( <> @@ -39,7 +59,7 @@ export function IncomeView({ rows, palette }: { rows: IncomeRow[]; palette: { in {totals.profitable} profitable - {rows.length - totals.profitable} loss-making + {visible.length - totals.profitable} loss-making {totals.invoiceVat > 0 && ( @@ -91,6 +111,31 @@ export function IncomeView({ rows, palette }: { rows: IncomeRow[]; palette: { in ))} + {/* Status filter. Stands down machines are hidden by default; this is the only + way to see them, so the count is stated rather than leaving a silent gap. */} +
+ {([ + { id: "active", label: "Active", n: activeN }, + { id: "all", label: "All", n: rows.length }, + { id: "inactive", label: "Inactive", n: inactiveN }, + ] as const).map((o) => ( + + ))} +
+ {/* Part B.3: mobile sort dropdown */}
@@ -107,6 +152,23 @@ export function IncomeView({ rows, palette }: { rows: IncomeRow[]; palette: { in
+ {/* grid, not flex-wrap: a wrapping flex row sizes tiles by CONTENT, which is the + ragged-left-edge trap AGENTS.md records for /income/invoices. */} +
+
+
YTD Income
+
{ZAR(totals.totalIncome)}
+
+
= 0 ? "from-[#EDAE49] to-[#D1495B]" : "from-[#D1495B] to-[#003D5B]" + } rounded-2xl px-5 py-3 text-white shadow-lg`} + > +
YTD Profit
+
{ZAR(totals.totalProfit)}
+
+
+ {view === "cards" ? ( = 0; - let j = i; - while ( - j + 1 < points.length && - points[j + 1].profit != null && - (points[j + 1].profit as number) >= 0 === up - ) j++; - runs.push({ start: i, end: j, up }); - i = j + 1; - } - return runs; -} - export function MachineLineChart({ income, fixed, running, view = "lines", @@ -224,46 +202,26 @@ export function MachineLineChart({ runningKnown: points[i].runningKnown, })) as (Record & { month: string; runningKnown: boolean })[]; - const runs = splitRuns(points); - - // Recharts builds a categorical axis by concatenating the data of EVERY series, - // so a Line carrying its own `data` re-adds the month labels and the axis ends up - // reading "Jan … Aug, Jan … Aug" while the short series plots at the wrong months. - // Therefore every profit variant lives as its own key on the ONE shared dataset and - // no Line is given a `data` prop: profit is nulled outside its own run, and + // Recharts builds a categorical axis by concatenating the data of EVERY series, so a + // Line carrying its own `data` re-adds the month labels and the axis renders + // "Jan … Aug, Jan … Aug" while that series plots at the wrong months. The shared + // helper therefore writes each profit variant as a key on the ONE dataset and no + // Line is given a `data` prop: profit is nulled outside its own run, and // connectNulls={false} then draws exactly that run, in the right place. - const seriesDefs: { key: string; stroke: string }[] = []; - const assignRun = (key: string, keep: number[]) => { - const on = new Set(keep); - for (let i = 0; i < data.length; i++) data[i][key] = on.has(i) ? points[i].profit : null; - }; - + // // One flat series per same-sign run, then one gradient series per genuine crossing. // Recharts 2 draws a Line as a single , so cannot recolour a segment — - // separate series are the only way to get per-segment colour. - runs.forEach((r, idx) => { - if (r.end <= r.start) return; - const key = `profitRun${idx}`; - const keep: number[] = []; - for (let i = r.start; i <= r.end; i++) keep.push(i); - assignRun(key, keep); - seriesDefs.push({ key, stroke: r.up ? UP : DOWN }); - }); - - const crossings = runs.flatMap((a, idx) => { - const b = runs[idx + 1]; - // Not adjacent means a null month sits between them — do not bridge the gap. - if (!b || a.end + 1 !== b.start) return []; - const key = `profitCross${idx}`; - assignRun(key, [a.end, b.start]); - const def = { key, stroke: `url(#${uid}-g${idx})` }; - seriesDefs.push(def); - return [{ - key: `grad-${a.end}-${b.start}`, - id: `${uid}-g${idx}`, - from: a.up ? UP : DOWN, - to: b.up ? UP : DOWN, - }]; + // separate series are the only way to get per-segment colour. The dashboard's + // IncomeAreaChart uses this same helper, so the two cannot drift apart. + const { series: seriesDefs, gradients: profitGradients } = buildProfitSeries({ + profit: points.map((p) => p.profit), + upColor: UP, + downColor: DOWN, + idPrefix: uid, + mutate: (key, keep) => { + const on = new Set(keep); + for (let i = 0; i < data.length; i++) data[i][key] = on.has(i) ? points[i].profit : null; + }, }); const fmt = (v: number) => @@ -332,8 +290,8 @@ export function MachineLineChart({ {/* Defined before use so the fade ids exist when the Lines below mount. */} - {crossings.map((c) => ( - + {profitGradients.map((c) => ( + diff --git a/lib/chart-profit.ts b/lib/chart-profit.ts new file mode 100644 index 0000000..f531f66 --- /dev/null +++ b/lib/chart-profit.ts @@ -0,0 +1,115 @@ +// Per-segment colouring for a profit line. +// +// Recharts draws a as a single path, so cannot recolour one +// segment of it — Cells only work on . Getting a line that is green where the +// month made money and red where it did not therefore means one per same-sign +// run, plus one extra per genuine crossing to carry the gradient between them. +// +// This is the shared implementation. `/charts` (MachineLineChart) and the dashboard +// (IncomeAreaChart) both draw the same shape of series, and two private copies of this +// logic would drift the moment one of them was fixed. +// +// The colours come from the tokens already ruled for this purpose — see AGENTS.md: +// `--color-chart-profit` is a deliberate exception to the "never change the palette" +// rule, and `--color-chart-loss` has its own token precisely so a loss does not follow +// the Running line's colour when that hue is recoloured. + +export type ProfitRun = { + /** Index of the first month in the run. */ + start: number; + /** Index of the last month in the run, inclusive. */ + end: number; + /** true = the month made money (or broke even). */ + up: boolean; +}; + +// Split a series into maximal contiguous runs of the same sign. A null month ends a +// run, so the line never bridges a month we have no figure for — treating a missing +// month as 0 would draw a confident line across a gap in the data. +export function splitProfitRuns(profit: readonly (number | null)[]): ProfitRun[] { + const runs: ProfitRun[] = []; + let i = 0; + while (i < profit.length) { + const first = profit[i]; + if (first == null) { + i++; + continue; + } + const up = first >= 0; + let j = i; + while ( + j + 1 < profit.length && + profit[j + 1] != null && + (profit[j + 1] as number) >= 0 === up + ) { + j++; + } + runs.push({ start: i, end: j, up }); + i = j + 1; + } + return runs; +} + +export type ProfitSeriesDef = { key: string; stroke: string }; +export type ProfitGradient = { id: string; from: string; to: string }; + +/** + * Write one key per profit variant onto the SHARED chart data, and describe the series + * and gradients to draw. + * + * Nothing is given a `data` prop of its own: Recharts builds a categorical axis by + * concatenating the data of every series, so a Line carrying a short `data` re-adds the + * month labels and the axis renders "Jan … Aug, Jan … Aug" while that series plots at + * the wrong months. Instead each variant is a key on the one dataset, nulled outside + * its own run, and `connectNulls={false}` then draws exactly that run in the right + * place. + * + * `mutate` is called for every variant with a `keep` index list and the key to write. + */ +export function buildProfitSeries({ + profit, + upColor, + downColor, + idPrefix, + mutate, +}: { + profit: readonly (number | null)[]; + upColor: string; + downColor: string; + /** Unique per chart instance, or two charts would share one gradient def. */ + idPrefix: string; + mutate: (key: string, keep: number[]) => void; +}): { series: ProfitSeriesDef[]; gradients: ProfitGradient[] } { + const runs = splitProfitRuns(profit); + const series: ProfitSeriesDef[] = []; + const gradients: ProfitGradient[] = []; + + const keepRange = (from: number, to: number) => { + const keep: number[] = []; + for (let i = from; i <= to; i++) keep.push(i); + return keep; + }; + + // A run of a single point has no segment to draw, so it is skipped. The point still + // shows as a dot on the axis below, which is where a lone month's profit belongs. + runs.forEach((r, idx) => { + if (r.end <= r.start) return; + const key = `profitRun${idx}`; + mutate(key, keepRange(r.start, r.end)); + series.push({ key, stroke: r.up ? upColor : downColor }); + }); + + // One gradient series per genuine crossing: two adjacent runs, same sign flip. + runs.forEach((a, idx) => { + const b = runs[idx + 1]; + // Not adjacent means a null month sits between them — never bridge that gap. + if (!b || a.end + 1 !== b.start) return; + const key = `profitCross${idx}`; + const id = `${idPrefix}-g${idx}`; + mutate(key, [a.end, b.start]); + series.push({ key, stroke: `url(#${id})` }); + gradients.push({ id, from: a.up ? upColor : downColor, to: b.up ? upColor : downColor }); + }); + + return { series, gradients }; +} diff --git a/lib/rows.ts b/lib/rows.ts index 034468c..6af22b9 100644 --- a/lib/rows.ts +++ b/lib/rows.ts @@ -14,6 +14,10 @@ export type IncomeRow = { running: number; ytdProfit: number; lastActualMonth: number; // Part A.3: clip x-axis + // Machines the client has stood down. They still hold their history, so they are kept + // in the data and filtered in the view rather than dropped here — /income defaults to + // active only, but the client can switch to All or Inactive. + isActive: boolean; // Portion of `income` that came from invoices, and how much of that is VAT. // Booked amounts are VAT-inclusive (see lib/invoice-booking-policy.json), so without // this the per-machine figure is not explainable. @@ -42,6 +46,7 @@ export function buildIncomeRows( if (!a.code) continue; byAsset.set(a.id, { id: a.id, code: a.code, name: a.name, + isActive: a.is_active !== false, months: Array(12).fill(null), fixedMonths: Array(12).fill(null), runningMonths: Array(12).fill(null),