From ea94754f02bb022eb827550d2496237c7f54f57f Mon Sep 17 00:00:00 2001 From: David Date: Sat, 3 Oct 2026 19:09:17 +0200 Subject: [PATCH] Dashboard: income per site, one bar per site for a chosen month MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app/page.tsx | 28 ++++++- components/DashboardCharts.tsx | 141 ++++++++++++++++++++++++++++++++- 2 files changed, 165 insertions(+), 4 deletions(-) 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`} /> + + } + /> + + + +
+
+
+ + {billed.length} of {rows.length} site{rows.length === 1 ? "" : "s"} billed + + {ZAR(total)} +
+ + )} +
+ ); +} + export function OverheadDonut({ data }: { data: { name: string; value: number }[] }) { return (