diff --git a/app/page.tsx b/app/page.tsx
index dc2b78c..cc3f783 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -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 (
-
+
Dashboard
@@ -148,6 +165,11 @@ export default async function DashboardPage() {
+
+ {/* 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. */}
+
);
diff --git a/components/DashboardCharts.tsx b/components/DashboardCharts.tsx
index beb04f6..aef34cf 100644
--- a/components/DashboardCharts.tsx
+++ b/components/DashboardCharts.tsx
@@ -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 (
+
+
Income per site
+
+ No site billing yet. Sites appear here as soon as an invoice is imported.
+
+
+ );
+ }
+
+ return (
+
+ {/* 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. */}
+
+
+
Income per site
+
+ {billed.length ? `Billed incl VAT · ${label}` : `No site was billed in ${label}`}
+
+
+
+
+
+
+
+
+ {billed.length === 0 ? (
+
+ {/* 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.`}
+
+ ) : (
+ <>
+ {/* 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. */}
+
+
+
+ {/* 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. */}
+
+
+
+
+
+
+
+
+ `${(v / 1000).toFixed(0)}K`} />
+
+ }
+ />
+
+
+
+