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 (