Add PocketBase login, first-sign-in password changes, account settings, and user activation, reactivation and deletion with self-removal guards. Protect application pages and APIs and keep private pages out of PWA caches. Resolve plant locations from site assignments and improve map controls, profit chart views, navigation and mobile table layouts. Validation: production build and TypeScript passed; 28 unit tests passed. Backend access integration tests and mobile verification are documented.
370 lines
16 KiB
TypeScript
370 lines
16 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
ComposedChart, Line, LineChart, Bar, AreaChart, Area, Cell,
|
|
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine,
|
|
} from "recharts";
|
|
import { useEffect, useId, useState } from "react";
|
|
import { MONTH_NAMES } from "@/lib/data";
|
|
import { computeProfit, type ProfitOptions } from "@/lib/profit";
|
|
import { buildProfitSeries } from "@/lib/chart-profit";
|
|
|
|
export type ChartView = "lines" | "stack" | "profit" | "profit-line";
|
|
|
|
// Which lines the card draws. A hidden series disappears from the plot AND from
|
|
// its axis, so the reader never sees an axis for a line that is not there.
|
|
export type SeriesVisible = {
|
|
income?: boolean;
|
|
fixed?: boolean;
|
|
running?: boolean;
|
|
profit?: boolean;
|
|
};
|
|
|
|
type Props = {
|
|
income: (number | null)[];
|
|
fixed: (number | null)[];
|
|
running: (number | null)[];
|
|
view?: ChartView;
|
|
incomeColor?: string;
|
|
fixedColor?: string;
|
|
runningColor?: string;
|
|
profitColor?: string;
|
|
lossColor?: string;
|
|
// Which profit basis this chart shows. lib/profit.ts stays the single source of
|
|
// truth for the formula; this only decides which basis these cards read on.
|
|
includeRunningCost?: boolean;
|
|
showProfitLine?: boolean;
|
|
series?: SeriesVisible;
|
|
};
|
|
|
|
function readVar(name: string, fallback: string): string {
|
|
if (typeof window === "undefined") return fallback;
|
|
const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
|
return v || fallback;
|
|
}
|
|
|
|
// Round 4 mobile rule: no chart text under 12px on a phone, so ticks step up to 12
|
|
// below sm and stay at 10 on desktop where there is room for them.
|
|
function useIsNarrow() {
|
|
const [narrow, setNarrow] = useState(false);
|
|
useEffect(() => {
|
|
const mq = window.matchMedia("(max-width: 639px)");
|
|
const sync = () => setNarrow(mq.matches);
|
|
sync();
|
|
mq.addEventListener("change", sync);
|
|
return () => mq.removeEventListener("change", sync);
|
|
}, []);
|
|
return narrow;
|
|
}
|
|
|
|
type Point = {
|
|
month: string;
|
|
profit: number | null;
|
|
// false = we have no running-cost row for this month. `rows.ts` leaves
|
|
// runningMonths null when there is no row, so this is exactly "no data".
|
|
runningKnown: boolean;
|
|
};
|
|
|
|
// The profit line is drawn as several series (one per same-sign run, one per
|
|
// crossing, plus a transparent one that carries the dots) so it can be coloured
|
|
// per segment. Those internal keys are an implementation detail and must never
|
|
// reach the user, and "profit" is a computed figure rather than a raw column, so
|
|
// its own Tooltip formatter cannot label it. A custom tooltip is the only place
|
|
// that knows both: it drops every profit-backed series and adds one clean row.
|
|
type TooltipItem = {
|
|
dataKey?: string | number;
|
|
name?: string;
|
|
value?: number | string | null;
|
|
color?: string;
|
|
stroke?: string;
|
|
payload?: { profit?: number | null; runningKnown?: boolean };
|
|
};
|
|
type TooltipProps = {
|
|
active?: boolean;
|
|
label?: string | number;
|
|
payload?: TooltipItem[];
|
|
showProfit: boolean;
|
|
fmt: (v: number) => string;
|
|
up: string;
|
|
down: string;
|
|
};
|
|
|
|
const INTERNAL_SERIES = /^(profitRun|profitCross)/;
|
|
|
|
function ChartTooltip({ active, label, payload, showProfit, fmt, up, down }: TooltipProps) {
|
|
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_SERIES.test(key)) continue;
|
|
// The dot-carrying "profit" series is replaced by the single computed row below.
|
|
if (key === "profit") continue;
|
|
const name = p.name ?? key;
|
|
const value = p.value;
|
|
if (value == null) continue;
|
|
rows.push({ name, text: fmt(Number(value)), color: p.color || p.stroke || "#9ca3af" });
|
|
}
|
|
|
|
if (showProfit) {
|
|
const row = payload[0]?.payload;
|
|
if (row && row.profit != null) {
|
|
rows.push({
|
|
name: "Profit",
|
|
text: fmt(row.profit),
|
|
color: row.profit >= 0 ? up : down,
|
|
});
|
|
}
|
|
}
|
|
|
|
if (!rows.length) return null;
|
|
|
|
return (
|
|
// Reuse the recharts class names so globals.css keeps styling this tooltip.
|
|
<div className="recharts-default-tooltip">
|
|
<div className="recharts-tooltip-label">{label}</div>
|
|
{rows.map((r) => (
|
|
<div
|
|
key={r.name}
|
|
className="recharts-tooltip-item"
|
|
style={{ display: "flex", alignItems: "center", gap: 6, marginTop: 2 }}
|
|
>
|
|
<span
|
|
style={{ width: 8, height: 8, borderRadius: 2, background: r.color, flexShrink: 0 }}
|
|
/>
|
|
<span style={{ opacity: 0.65 }}>{r.name}</span>
|
|
<span style={{ marginLeft: "auto", fontWeight: 600 }}>{r.text}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function MachineLineChart({
|
|
income, fixed, running,
|
|
view = "lines",
|
|
incomeColor, fixedColor, runningColor, profitColor, lossColor,
|
|
includeRunningCost = false,
|
|
showProfitLine = true,
|
|
series,
|
|
}: Props) {
|
|
const on = {
|
|
income: view !== "profit-line" && (series?.income ?? true),
|
|
fixed: view !== "profit-line" && (series?.fixed ?? true),
|
|
running: view !== "profit-line" && (series?.running ?? true),
|
|
profit: view === "profit-line" || ((series?.profit ?? true) && showProfitLine),
|
|
};
|
|
// The amount axis exists only if some non-profit line is actually drawn.
|
|
const showAmountAxis = on.income || on.fixed || on.running;
|
|
const INC = incomeColor ?? readVar("--color-chart-income", "#0090A6");
|
|
const FIX = fixedColor ?? readVar("--color-chart-fixed", "#BE851F");
|
|
const RUN = runningColor ?? readVar("--color-chart-running", "#8B5CF6");
|
|
const UP = profitColor ?? readVar("--color-chart-profit", "#7A9E06");
|
|
// Loss has its own token — it must not follow the Running line's colour.
|
|
const DOWN = lossColor ?? readVar("--color-chart-loss", "#C13B4D");
|
|
|
|
const narrow = useIsNarrow();
|
|
const tickSize = narrow ? 12 : 10;
|
|
// Unique per card: /charts renders one of these per machine, and a shared
|
|
// gradient id would make every card paint the first card's fade.
|
|
const uid = useId().replace(/[^a-zA-Z0-9]/g, "");
|
|
|
|
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);
|
|
|
|
// Profit stays null in a month with no income figure. Treating it as 0 would
|
|
// draw a green "monthly profit" dot for a month that was never billed.
|
|
const profitOpts: ProfitOptions = { includeRunningCost };
|
|
const points: Point[] = months.map((m, i) => ({
|
|
month: m,
|
|
profit: income[i] == null
|
|
? null
|
|
: computeProfit(
|
|
{ income: income[i] as number, fixed: fixed[i] ?? 0, running: running[i] ?? 0 },
|
|
profitOpts,
|
|
),
|
|
runningKnown: running[i] != null,
|
|
}));
|
|
|
|
const data = months.map((m, i) => ({
|
|
month: m,
|
|
income: income[i] ?? null,
|
|
fixed: fixed[i] ?? null,
|
|
running: running[i] ?? null,
|
|
profit: points[i].profit,
|
|
// Carried onto the chart data because the profit dots read `payload` from here,
|
|
// not from the per-run `points` array.
|
|
runningKnown: points[i].runningKnown,
|
|
})) as (Record<string, unknown> & { month: string; runningKnown: boolean })[];
|
|
|
|
// 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.
|
|
//
|
|
// One flat series per same-sign run, then one gradient series per genuine crossing.
|
|
// Recharts 2 draws a Line as a single <Curve>, so <Cell> cannot recolour a segment —
|
|
// separate series are the only way to get per-segment colour. The dashboard's
|
|
// MonthlyProfitChart 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) =>
|
|
new Intl.NumberFormat("en-ZA", { style: "currency", currency: "ZAR", maximumFractionDigits: 0, notation: "compact" }).format(v);
|
|
|
|
// Keep zero inside the profit axis so "above/below zero" is always readable,
|
|
// whichever way every month happens to fall.
|
|
const profitAxis = {
|
|
yAxisId: "profit",
|
|
orientation: showAmountAxis ? "right" as const : "left" as const,
|
|
stroke: "#9ca3af",
|
|
tickLine: false,
|
|
axisLine: false,
|
|
tickFormatter: fmt,
|
|
// A standalone profit line owns the left axis, including labels on mobile.
|
|
// Only the secondary axis drops its ticks on a phone to protect plot width.
|
|
tick: narrow && showAmountAxis ? false : { fontSize: tickSize },
|
|
width: showAmountAxis ? (narrow ? 1 : 52) : 60,
|
|
// Function form so zero is always inside the range.
|
|
domain: ([min, max]: [number, number]): [number, number] => [Math.min(0, min), Math.max(0, max)],
|
|
};
|
|
|
|
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: tickSize }} tickLine={false} axisLine={false} interval="preserveStartEnd" />
|
|
<YAxis yAxisId="profit" stroke="#9ca3af" tick={{ fontSize: tickSize }} tickLine={false} axisLine={false} tickFormatter={fmt} width={60} />
|
|
<Tooltip formatter={(v: number) => fmt(v)} />
|
|
<ReferenceLine yAxisId="profit" y={0} stroke="#9ca3af" strokeDasharray="4 4" />
|
|
<Bar dataKey="profit" yAxisId="profit" radius={[4, 4, 0, 0]} fill={UP}>
|
|
{data.map((d, i) => (
|
|
<Cell key={i} fill={((d.profit as number | null) ?? 0) >= 0 ? UP : DOWN} />
|
|
))}
|
|
</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: tickSize }} tickLine={false} axisLine={false} interval="preserveStartEnd" />
|
|
{showAmountAxis && <YAxis yAxisId="amount" stroke="#9ca3af" tick={{ fontSize: tickSize }} tickLine={false} axisLine={false} tickFormatter={fmt} width={60} />}
|
|
<Tooltip formatter={(v: number) => fmt(v)} />
|
|
{on.fixed && <Bar dataKey="fixed" yAxisId="amount" stackId="a" fill={FIX} radius={[0, 0, 0, 0]} />}
|
|
{on.running && <Bar dataKey="running" yAxisId="amount" stackId="a" fill={RUN} radius={[4, 4, 0, 0]} barSize={20} />}
|
|
{on.income && <Line type="monotone" name="Income" dataKey="income" yAxisId="amount" 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 }}>
|
|
{/* Defined before use so the fade ids exist when the Lines below mount. */}
|
|
<defs>
|
|
{profitGradients.map((c) => (
|
|
<linearGradient key={c.id} id={c.id} x1="0%" y1="0%" x2="100%" y2="0%">
|
|
<stop offset="0%" stopColor={c.from} />
|
|
<stop offset="100%" stopColor={c.to} />
|
|
</linearGradient>
|
|
))}
|
|
</defs>
|
|
<CartesianGrid stroke="#f3f4f6" strokeDasharray="3 3" vertical={false} />
|
|
<XAxis dataKey="month" stroke="#9ca3af" tick={{ fontSize: tickSize }} tickLine={false} axisLine={false} interval="preserveStartEnd" />
|
|
{showAmountAxis && <YAxis yAxisId="amount" stroke="#9ca3af" tick={{ fontSize: tickSize }} tickLine={false} axisLine={false} tickFormatter={fmt} width={60} />}
|
|
{on.profit && <YAxis {...profitAxis} />}
|
|
{on.profit && (
|
|
<ReferenceLine yAxisId="profit" y={0} stroke="#9ca3af" strokeDasharray="4 4" />
|
|
)}
|
|
<Tooltip
|
|
formatter={(v: number) => fmt(v)}
|
|
content={<ChartTooltip showProfit={on.profit} fmt={fmt} up={UP} down={DOWN} />}
|
|
/>
|
|
|
|
{on.income && <Line type="monotone" name="Income" dataKey="income" yAxisId="amount" stroke={INC} strokeWidth={2.5} dot={{ r: 3, fill: INC }} connectNulls={false} />}
|
|
{on.fixed && <Line type="monotone" name="Fixed" dataKey="fixed" yAxisId="amount" stroke={FIX} strokeWidth={2.5} dot={{ r: 3, fill: FIX }} connectNulls={false} />}
|
|
{on.running && <Line type="monotone" name="Running" dataKey="running" yAxisId="amount" stroke={RUN} strokeWidth={2.5} dot={{ r: 3, fill: RUN }} connectNulls={false} />}
|
|
|
|
{/* These must be DIRECT children of LineChart — Recharts parses only
|
|
recognised child types and silently drops anything else, so wrapping
|
|
them in a <g> makes the whole profit line disappear. Each reads one
|
|
masked key off the shared data and carries no `data` of its own. */}
|
|
{on.profit &&
|
|
seriesDefs.map((s) => (
|
|
<Line
|
|
key={s.key}
|
|
type="linear"
|
|
dataKey={s.key}
|
|
yAxisId="profit"
|
|
stroke={s.stroke}
|
|
strokeWidth={4}
|
|
dot={false}
|
|
activeDot={false}
|
|
isAnimationActive={false}
|
|
connectNulls={false}
|
|
/>
|
|
))}
|
|
{/* Dots ride their own transparent Line so each month can be coloured by its
|
|
own sign — filled = running cost known, hollow = no running-cost row for
|
|
that month, so that month's figure is optimistic. */}
|
|
{on.profit && (
|
|
<Line
|
|
type="linear"
|
|
dataKey="profit"
|
|
yAxisId="profit"
|
|
stroke="none"
|
|
strokeWidth={0}
|
|
connectNulls={false}
|
|
activeDot={false}
|
|
isAnimationActive={false}
|
|
dot={(props: { cx?: number; cy?: number; index?: number; payload?: unknown }) => {
|
|
const { cx, cy, index } = props;
|
|
const p = props.payload as
|
|
| { profit?: number | null; runningKnown?: boolean }
|
|
| undefined;
|
|
// LineDot's type forbids returning null, so an unplottable month
|
|
// gets an empty group instead of a circle at NaN.
|
|
if (p?.profit == null || cx == null || cy == null || Number.isNaN(cy)) {
|
|
return <g key={`d-${index}`} />;
|
|
}
|
|
const c = p.profit >= 0 ? UP : DOWN;
|
|
return p.runningKnown
|
|
? <circle key={`d-${index}`} cx={cx} cy={cy} r={3.5} fill={c} stroke="#fff" strokeWidth={1} />
|
|
: <circle key={`d-${index}`} cx={cx} cy={cy} r={3.5} fill="#fff" stroke={c} strokeWidth={2} />;
|
|
}}
|
|
/>
|
|
)}
|
|
</LineChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
);
|
|
}
|