mega-plant/components/DashboardCharts.tsx
Dave 2594dead84 feat: add user access and improve plant dashboard
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.
2026-10-06 04:02:08 +02:00

518 lines
22 KiB
TypeScript

"use client";
import { useEffect, useId, useMemo, 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.
function DashTooltip({ active, payload, label }: TooltipProps<number, string>) {
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 (p.value == null) continue;
rows.push({ name: p.name ?? key, text: ZAR(Number(p.value)), color: p.color || p.stroke || "#9ca3af" });
}
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 dark = useIsDark();
return (
<div className="h-72">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart 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 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 />}
/>
<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)" />
</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>
);
}
// Monthly profit on its own scale, with a DOT per month.
//
// The months the business made money and the months it did not can be read at a glance.
// Dots are drawn by a second, invisible Line carrying `profit`, NOT by
// the run lines: a run of a single month has no segment and so no dot of its own, and a
// lone profitable month would simply disappear. One marker per month, coloured from that
// month's own sign, cannot miss one.
export function MonthlyProfitChart({
data,
}: {
data: { month: string; income: number; fixed: number; running: number; hasRunning?: boolean }[];
}) {
// Clip to the last month with an income figure, exactly as the other two charts do.
const lastIdx = data.findLastIndex((m) => m.income > 0);
const clipped = lastIdx >= 0 ? data.slice(0, lastIdx + 1) : data;
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, "");
// A month with no income figure is null, never 0 — 0 would plot a confident green
// dot 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; hasRunning?: boolean }
)[];
const { series, gradients } = buildProfitSeries({
profit,
upColor: UP,
downColor: DOWN,
idPrefix: `${uid}-mp`,
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;
},
});
// A month with no monthly_running_costs row has no running cost subtracted, so its
// profit is optimistic. /charts already marks those with a hollow dot; this chart is the
// same series, so it marks them the same way. The fill is --color-surface, which is the
// card's own background in both themes, so the line does not show through the middle.
const renderDot = (props: { key?: string; cx?: number; cy?: number; payload?: { profit: number | null; hasRunning?: boolean } }) => {
const d = props.payload;
if (!d || d.profit == null) return <g key={props.key} />;
const color = d.profit >= 0 ? UP : DOWN;
const hollow = d.hasRunning === false;
return (
<circle
key={props.key}
cx={props.cx}
cy={props.cy}
r={4.5}
fill={hollow ? "var(--color-surface)" : color}
stroke={color}
strokeWidth={2}
/>
);
};
const upMonths = profit.filter((v) => v != null && v >= 0).length;
const lossMonths = profit.filter((v) => v != null && v < 0).length;
const optimistic = chartData.some((d) => d.hasRunning === false);
return (
<section className="lg:col-span-3 bg-white dark:bg-surface rounded-2xl border border-gray-100 dark:border-border p-6 shadow-sm">
{/* flex-wrap, so on mobile the legend drops under the heading instead of squeezing
the title to one word per line. */}
<div className="flex flex-wrap items-end justify-between gap-3 mb-2">
<div>
<h2 className="text-base font-semibold text-[#003D5B] dark:text-white">Monthly profit</h2>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
Income less fixed and running costs, per month · 2026
</p>
</div>
<div className="flex flex-wrap items-center gap-4 text-xs">
<LegendDot color={UP} label="Made profit" />
<LegendDot color={DOWN} label="Made a loss" />
{optimistic && <LegendDot color="transparent" label="No running cost yet" hollow />}
</div>
</div>
<div className="h-56">
<ResponsiveContainer width="100%" height="100%">
{/* ComposedChart, not LineChart: the dots ride a Line with a transparent stroke,
and LineChart's own parsing of children is the part of Recharts that quietly
drops a series. */}
<ComposedChart data={chartData} margin={{ top: 12, right: 16, left: 0, bottom: 0 }}>
<defs>
{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={{ stroke: dark ? "rgba(255,255,255,0.18)" : "rgba(0,0,0,0.12)" }}
content={<ProfitTooltip up={UP} down={DOWN} />}
/>
{/* The marker line. `transparent`, not `none`, so Recharts still builds the
path and hands each point to renderDot. */}
<Line
type="monotone"
dataKey="profit"
stroke="transparent"
strokeWidth={0}
dot={renderDot}
activeDot={false}
connectNulls={false}
isAnimationActive={false}
legendType="none"
/>
{/* One line per same-sign run, direct children of the chart — Recharts parses
only known child types, so a <g> wrapper makes the whole thing vanish. */}
{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>
<div className="flex flex-wrap items-center justify-between gap-2 text-xs text-gray-500 dark:text-gray-400 mt-2">
<span>
{upMonths} of {upMonths + lossMonths} months made a profit
</span>
{optimistic && <span>A hollow dot is a month with no running cost booked yet.</span>}
</div>
</section>
);
}
// The hovered MONTH, not the run keys. Recharts feeds a tooltip one entry per series, so
// the per-run keys would otherwise surface as "profitRun0" and "profitCross0" and "profit"
// would appear three or four times over.
function ProfitTooltip({
active,
payload,
up,
down,
}: {
active?: boolean;
payload?: { payload?: { month: string; profit: number | null; hasRunning?: boolean } }[];
up: string;
down: string;
}) {
const d = payload?.[0]?.payload;
if (!active || !d || d.profit == null) return null;
const isUp = d.profit >= 0;
return (
<div className="recharts-default-tooltip">
<div className="recharts-tooltip-label">{d.month}</div>
<ul className="recharts-tooltip-items" style={{ listStyle: "none", margin: 0, padding: 0 }}>
<li style={{ display: "flex", alignItems: "center", gap: 8, padding: "1px 0" }}>
<span style={{ width: 8, height: 8, borderRadius: 4, flexShrink: 0, background: isUp ? up : down }} />
<span className="recharts-tooltip-item-name" style={{ color: "inherit", opacity: 0.85 }}>
{isUp ? "Profit" : "Loss"}
</span>
<span
className="recharts-tooltip-item-value"
style={{ marginLeft: "auto", fontWeight: 600, color: "inherit" }}
>
{ZAR(d.profit)}
</span>
</li>
{d.hasRunning === false && (
<li style={{ fontSize: 11, opacity: 0.8, paddingTop: 2 }}>no running cost booked yet</li>
)}
</ul>
</div>
);
}
function LegendDot({ color, label, hollow }: { color: string; label: string; hollow?: boolean }) {
return (
<div className="flex items-center gap-1.5">
<div
className="w-2.5 h-2.5 rounded-full shrink-0"
style={hollow ? { border: "2px solid var(--color-text-soft)" } : { background: color }}
/>
<span className="text-gray-500 dark:text-gray-400">{label}</span>
</div>
);
}
// One bar per site, for one month of the site's billing series.
//
// HORIZONTAL, not vertical: the x axis is the site NAME, so a site with a long name
// ("Halfway Gardens") gets a real label instead of an angled abbreviation, and the card
// grows DOWNWARD as sites are added rather than squeezing them into narrower bars. The
// dashboard used to carry a full-width chart per row, but a third of that width is
// plenty for five bars and costs nothing to read.
//
// `months` is 12 slots of billed amount incl VAT, null = no invoice that month — the same
// convention as SiteSummary.months, and for the same reason: a site that was not billed
// draws NO bar, which is different from a bar of zero.
export type SiteIncomeRow = { name: string; months: (number | null)[] };
export function IncomePerSiteChart({
rows,
year,
monthNames,
}: {
rows: SiteIncomeRow[];
year: number;
monthNames: string[];
}) {
// Default to the LATEST month that actually has billing, not the calendar month.
// Billing runs behind the calendar — the September invoices only land in October —
// so a "current month" default would open on an empty card and read as broken.
const latestBilled = useMemo(() => {
for (let i = 11; i >= 0; i--) if (rows.some((r) => r.months[i] != null)) return i;
return 11;
}, [rows]);
const [idx, setIdx] = useState(latestBilled);
const INC = readVar("--color-chart-income", "#0090A6");
const dark = useIsDark();
// Largest first: a vertical-layout Recharts bar chart draws the first row at the top.
const billed = rows
.map((r) => ({ name: r.name, income: r.months[idx] ?? null }))
.filter((r): r is { name: string; income: number } => r.income != null)
.sort((a, b) => b.income - a.income);
const total = billed.reduce((s, r) => s + r.income, 0);
const label = `${monthNames[idx] ?? ""} ${year}`;
if (!rows.length) {
return (
<section className="bg-white dark:bg-surface rounded-2xl border border-gray-100 dark:border-border p-6 shadow-sm">
<h2 className="text-base font-semibold text-[#003D5B] dark:text-white">Income per site</h2>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
No site billing yet. Sites appear here as soon as an invoice is imported.
</p>
</section>
);
}
return (
<section className="flex flex-col bg-white dark:bg-surface rounded-2xl border border-gray-100 dark:border-border p-6 shadow-sm">
{/* flex-wrap, not a fixed two-column row: on mobile the select drops under the
heading instead of squeezing the title to two words per line. */}
<div className="flex flex-wrap items-end justify-between gap-3 mb-4">
<div>
<h2 className="text-base font-semibold text-[#003D5B] dark:text-white">Income per site</h2>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{billed.length ? `Billed incl VAT · ${label}` : `No site was billed in ${label}`}
</p>
</div>
<div>
<label className="sr-only" htmlFor="site-income-month">Month</label>
<select
id="site-income-month"
value={idx}
onChange={(e) => setIdx(Number(e.target.value))}
className="min-h-[44px] px-3 text-xs rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-white/5"
>
{monthNames.map((m, i) => (
<option key={m} value={i}>
{m} {year}
{i === latestBilled ? " · latest" : ""}
</option>
))}
</select>
</div>
</div>
{billed.length === 0 ? (
<p className="text-sm text-gray-500 dark:text-gray-400 py-6 text-center">
{/* Template strings, not JSX text between two expressions: a newline between
two children is swallowed by JSX, which printed "Nov 2026.10 sites". */}
{`Nothing was invoiced to any site in ${label}.`}{" "}
{`The ${rows.length} site${rows.length === 1 ? "" : "s"} on file carried over from earlier months.`}
</p>
) : (
<>
{/* flex-1 + items-center, so the bars sit in the MIDDLE of whatever height the
grid row gives this card instead of clinging to the top with a block of dead
space beneath them. `min-h-0` is what lets a flex child shrink at all. The
inner div keeps its own height, so the chart never collapses. */}
<div className="flex-1 min-h-0 flex items-center">
<div className="w-full" style={{ height: Math.max(160, billed.length * 38 + 24) }}>
<ResponsiveContainer width="100%" height="100%">
{/* layout="vertical" swaps the axes: names go down the left, rand across
the bottom. The number format has to move to the X axis with them. */}
<BarChart data={billed} layout="vertical" margin={{ top: 4, right: 12, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="gSiteBar" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stopColor={INC} />
<stop offset="100%" stopColor={INC} stopOpacity={0.5} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" horizontal={false} />
<XAxis type="number" tickLine={false} axisLine={false} tickFormatter={(v) => `${(v / 1000).toFixed(0)}K`} />
<YAxis
type="category"
dataKey="name"
tickLine={false}
axisLine={false}
width={96}
interval={0}
/>
<Tooltip
cursor={{ fill: dark ? "rgba(255,255,255,0.06)" : "rgba(0,0,0,0.03)" }}
content={<DashTooltip />}
/>
<Bar dataKey="income" name="Billed (incl VAT)" fill="url(#gSiteBar)" radius={[0, 8, 8, 0]} barSize={18} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
<div className="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400 mt-3">
<span>
{billed.length} of {rows.length} site{rows.length === 1 ? "" : "s"} billed
</span>
<span className="tabular-nums font-medium text-[#003D5B] dark:text-white">{ZAR(total)}</span>
</div>
</>
)}
</section>
);
}
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;