Dashboard: income per site, one bar per site for a chosen month

Answers "which site earned what this month" without leaving the overview.
Horizontal bars, so the site NAME is the y axis and the card grows downward
as sites are added rather than squeezing the bars narrower.

The month defaults to the latest month that actually carries billing, not the
calendar month: the September invoices only land in October, so a "current
month" default opened on an empty card that read as broken. A month picker
covers the rest of the year, and a month with no invoice says so in words
rather than drawing five zero-height bars.

Figures come from buildSiteSummaries, so this card and /sites are the same
numbers by construction — West Park Sep R 185 573, total R 780 003.61 on both.
The site's own fetches are wrapped: the dashboard must still render its own
numbers if the invoices collections are missing.

Also p-4 sm:p-8 on the page shell, which was p-8 at every width.
This commit is contained in:
David 2026-10-03 19:09:17 +02:00
parent def147bc73
commit ea94754f02
2 changed files with 165 additions and 4 deletions

View file

@ -1,8 +1,9 @@
import { getAssets, getMonthlyIncome, getActualMonthlyIncome, getRunningCosts, getOverheads, lastActualMonth, MONTH_NAMES, type Asset, type MonthlyIncome, type MonthlyRunningCost, type Overhead } from "@/lib/data";
import { getAssets, getMonthlyIncome, getActualMonthlyIncome, getRunningCosts, getOverheads, getSites, getInvoices, getInvoiceLines, joinBookedLines, lastActualMonth, MONTH_NAMES, type Asset, type MonthlyIncome, type MonthlyRunningCost, type Overhead } from "@/lib/data";
import { buildSiteSummaries, latestInvoiceYear } from "@/lib/sites";
import { computeProfit } from "@/lib/profit";
import { ZAR } from "@/lib/utils";
import { Truck, Wallet, TrendingUp, TrendingDown } from "lucide-react";
import { IncomeAreaChart, IncomeBarChart, OverheadDonut, PIE_COLORS } from "@/components/DashboardCharts";
import { IncomeAreaChart, IncomeBarChart, IncomePerSiteChart, OverheadDonut, PIE_COLORS, type SiteIncomeRow } from "@/components/DashboardCharts";
// Reads PocketBase on every request. Without this Next tries to prerender the
// page at build time, which makes `docker build` depend on the database being
@ -19,6 +20,22 @@ export default async function DashboardPage() {
getOverheads(),
]);
// The site card is a bonus, not the page: if the `sites` / `invoices` collections
// are missing (scripts/setup-invoice-collections.mjs has not been run) the dashboard
// must still render its own numbers rather than 500 the front page of the app.
let siteRows: SiteIncomeRow[] = [];
let siteYear = new Date().getFullYear();
try {
const [sites, invoices, lines] = await Promise.all([getSites(), getInvoices(), getInvoiceLines()]);
siteRows = buildSiteSummaries(sites, invoices, joinBookedLines(lines, assets), assets).map((r) => ({
name: r.name,
months: r.months,
}));
siteYear = latestInvoiceYear(invoices);
} catch (e) {
console.error("dashboard: site income unavailable", e);
}
const totalPlantCount = assets.filter((a: Asset) => a.category === "Plant" && a.code).length;
const totalPurchaseValue = assets.reduce((s: number, a: Asset) => s + (a.purchase_value || 0), 0);
const totalMonthlyIncl = overheads.reduce((s: number, o: Overhead) => s + (o.amount_incl || 0), 0);
@ -53,7 +70,7 @@ export default async function DashboardPage() {
const periodLabel = lastActual > 0 ? `Jan–${MONTH_NAMES[lastActual - 1]} actuals` : "no data";
return (
<div className="p-8 max-w-[1400px]">
<div className="p-4 sm:p-8 max-w-[1400px]">
<div className="flex items-end justify-between mb-6">
<div>
<h1 className="text-2xl font-semibold text-[#003D5B] dark:text-white">Dashboard</h1>
@ -148,6 +165,11 @@ export default async function DashboardPage() {
</div>
<IncomeBarChart data={monthsIncome} />
</section>
{/* The third slot of this row was empty. Income per site fills it: the same
billed-incl-VAT figures the /sites cards show, so a site's bar and its
/sites card can never disagree. */}
<IncomePerSiteChart rows={siteRows} year={siteYear} monthNames={MONTH_NAMES} />
</div>
</div>
);

View file

@ -1,6 +1,6 @@
"use client";
import { useEffect, useId, useState } from "react";
import { useEffect, useId, useMemo, useState } from "react";
import {
ComposedChart, Area, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip,
ResponsiveContainer, PieChart, Pie, Cell,
@ -234,6 +234,145 @@ export function IncomeBarChart({ data }: { data: { month: string; income: number
);
}
// 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">