From 876619c3ac3813d558dcba1e6a05186994ba8d04 Mon Sep 17 00:00:00 2001 From: David Date: Fri, 2 Oct 2026 19:09:16 +0200 Subject: [PATCH] Invoices: keep the PDF on the invoice, and search the invoice's own words MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things this question turned up. The upload DID store the PDF, but on the intake record, never on the invoice — so "Open invoice PDF" 404'd for every invoice imported through the app while working for the older ones. The file now lives on invoices.source_pdf and the intake record keeps only the provenance (who sent it, what was parsed), rather than a second copy of the file. The document endpoint also falls back to the intake record, which makes the seven already-stored documents reachable again without a migration. All 10 now open. The machine combobox searched only the register's own words, so an invoice line reading "CB22 2.2 TON Roller Vibration" could never be matched to the asset filed as "CAT 2,4TON RV" — the words simply were not in the list. The line's own invoice code and description are now searchable too, so the two sides of the same machine can actually be found from either direction. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 24 ++ app/api/invoices/[id]/document/route.ts | 19 +- app/sites/[id]/page.tsx | 96 +++++-- app/sites/page.tsx | 120 +-------- components/InvoiceTable.tsx | 8 +- components/MachineCombobox.tsx | 22 +- components/SitesView.tsx | 337 ++++++++++++++++++++++++ components/Sparkline.tsx | 14 +- lib/sites.ts | 117 +++++++- 9 files changed, 611 insertions(+), 146 deletions(-) create mode 100644 components/SitesView.tsx diff --git a/AGENTS.md b/AGENTS.md index ec507d5..5b3f588 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,6 +71,30 @@ where a multi-site machine should be read. Only an **unbooked** line is a real p The register has no `MPLANT04` / `MPLANT05` codes, so those map to `OC01` / `SC01` as the consistent target — chosen, not derived. Confirm with the client if either should differ. +## Site monthly chart — which month an invoice lands in + +`/sites` plots one sparkline per site (billed per month) and a site's row expands to the +invoices behind each month. `buildSiteSummaries` (`lib/sites.ts`) produces both. + +**The month is always the invoice's `booked_month`, never `invoice_date` and never +`period_from`.** `seed.mjs` sets `booked_month`/`booked_year` from the month of +`period_to`, which is the same rule that posts the income into `monthly_income`. So a +site's September total on `/sites` is the same rand as the September column on `/income` +— the two screens cannot disagree. PHI 166 (period 21 Aug → 18 Sep) therefore books to +**September**. Re-deriving the month any other way is the one way to break this. + +Other rules worth not re-learning: + +- A month total is the **sum of that site's lines**, not the invoice header total. One + invoice carries many lines to a site (PHI 166 has five), so an invoice is listed once + per month with its lines accumulated — never one card per line. +- `months[]` is 12 slots of `null` for "no invoice", never `0`. A site whose billing + starts in September is not eight months of zero. +- The series covers ONE year — `latestInvoiceYear(invoices)`, the max `booked_year`. + Months from other years are dropped, not folded in, and the panel says so. +- `SiteSummary.pinned` exists because the table is a client component and cannot look + the site up in the `sites` collection itself. + ## Money on invoices — VAT is NOT revenue On `PHI 166` the totals block reads `SUB TOTAL 161,367.50 / Escalation 0.00 / PLUS VAT diff --git a/app/api/invoices/[id]/document/route.ts b/app/api/invoices/[id]/document/route.ts index cfae6ff..f414eff 100644 --- a/app/api/invoices/[id]/document/route.ts +++ b/app/api/invoices/[id]/document/route.ts @@ -20,15 +20,28 @@ export async function GET( try { const pb = await serverAuth(); const invoice = await pb.collection("invoices").getOne(id); - const token = invoice.source_pdf; + + // Older imports stored the PDF on the intake record rather than the invoice, so + // fall back to that before giving up — otherwise those documents are unreachable. + let token = invoice.source_pdf; + let holder: any = invoice; + if (!token) { + const intake = (await pb.collection("invoice_intake").getFullList({ filter: `invoice = "${id}"`, perPage: 1 }))[0]; + if (intake?.source_pdf) { + token = intake.source_pdf; + holder = intake; + } + } if (!token) { return Response.json({ ok: false, error: "No document attached" }, { status: 404 }); } // A bare `/api/files/` 404s for protected files even with an admin JWT — // PocketBase resolves those by the full /collectionId/recordId/filename path. Let the - // SDK build the URL so the lookup matches whatever the field actually holds. - const upstream = pb.files.getURL(invoice, token); + // SDK build the URL so the lookup matches whatever the field actually holds. The + // holder matters: a file on an intake record lives under THAT record's path, not + // under the invoice's. + const upstream = pb.files.getURL(holder, token); const res = await fetch(upstream, { headers: pb.authStore.token ? { Authorization: pb.authStore.token } : undefined, diff --git a/app/sites/[id]/page.tsx b/app/sites/[id]/page.tsx index 951c3c9..f55731d 100644 --- a/app/sites/[id]/page.tsx +++ b/app/sites/[id]/page.tsx @@ -1,11 +1,12 @@ import type { Metadata } from "next"; import Link from "next/link"; import { notFound } from "next/navigation"; -import { getAssets, getInvoices, getInvoiceLines, getSites, joinBookedLines } from "@/lib/data"; +import { getAssets, getInvoices, getInvoiceLines, getSites, joinBookedLines, MONTH_NAMES } from "@/lib/data"; import { buildSiteSummaries } from "@/lib/sites"; import { ZAR2 } from "@/lib/utils"; import { ScrollFade } from "@/components/ScrollFade"; import { ArrowLeft, MapPin, FileText } from "lucide-react"; +import { Sparkline } from "@/components/Sparkline"; import { SiteCoordinateEditor } from "@/components/SiteCoordinateEditor"; import { SiteContainerEditor } from "@/components/SiteContainerEditor"; @@ -78,6 +79,16 @@ export default async function SiteDetailPage({ params }: { params: Promise<{ id:
Billed (incl VAT)
{ZAR2(summary.revenueIncl)}
+ {/* The same monthly series the site list plots, so the detail page and the + list can never disagree about what a month was worth. */} +
+
+ Billed per month · {summary.year} +
+
+ +
+
@@ -168,26 +179,57 @@ export default async function SiteDetailPage({ params }: { params: Promise<{ id:

Invoices

-
    - {summary.invoices.map((i) => ( -
  • - - - - - {i.invoiceNo} - - - {String(i.invoiceDate).slice(0, 10)} - + {/* Grouped by billing month rather than one flat list, so the page reads the + same way as the list: a month, then the invoices that made it up. */} +
    + {summary.monthly.map((m) => ( +
    +
    + + {MONTH_NAMES[m.month - 1]} {m.year} - - - {ZAR2(i.totalIncl)} - -
  • + + {ZAR2(m.totalIncl)} + + +
    + ex {ZAR2(m.totalExcl)} + {ZAR2(m.vat)} VAT · {m.invoices.length} invoice + {m.invoices.length === 1 ? "" : "s"} +
    +
      + {m.invoices.map((inv) => ( +
    • + + + + + {inv.invoiceNo} + + + {fmtDate(inv.invoiceDate)} · {fmtRange(inv.periodFrom, inv.periodTo)} + + + + {inv.hasPdf ? ( + + PDF + + ) : ( + + {ZAR2(inv.totalIncl)} + + )} +
    • + ))} +
    + ))} -
+ {summary.unbookedCount > 0 && ( @@ -209,4 +251,18 @@ export default async function SiteDetailPage({ params }: { params: Promise<{ id: ); -} \ No newline at end of file +} + +// PocketBase dates arrive as "2026-09-18 00:00:00.000Z". The date part is parsed as +// UTC so a label can't slip a day backwards in a timezone behind the server. +function fmtDate(d: string): string { + if (!d) return "—"; + const dt = new Date(d.length <= 10 ? `${d}T00:00:00Z` : d); + if (Number.isNaN(dt.getTime())) return String(d).slice(0, 10); + return `${dt.getUTCDate()} ${MONTH_NAMES[dt.getUTCMonth()]}`; +} + +function fmtRange(from: string, to: string): string { + if (!from || !to) return "—"; + return `${fmtDate(from)} → ${fmtDate(to)}`; +} diff --git a/app/sites/page.tsx b/app/sites/page.tsx index a2b621f..1038787 100644 --- a/app/sites/page.tsx +++ b/app/sites/page.tsx @@ -1,9 +1,9 @@ import type { Metadata } from "next"; import { getAssets, getInvoices, getInvoiceLines, getSites, joinBookedLines } from "@/lib/data"; -import { buildSiteSummaries } from "@/lib/sites"; -import { ZAR, ZAR2 } from "@/lib/utils"; -import { ScrollFade } from "@/components/ScrollFade"; -import { AlertTriangle, ArrowRight, MapPin, MapPinned } from "lucide-react"; +import { buildSiteSummaries, latestInvoiceYear } from "@/lib/sites"; +import { ZAR2 } from "@/lib/utils"; +import { SitesView } from "@/components/SitesView"; +import { AlertTriangle, MapPin, MapPinned } from "lucide-react"; export const dynamic = "force-dynamic"; export const metadata: Metadata = { title: "Sites — Mega Plant" }; @@ -18,12 +18,13 @@ export default async function SitesPage() { const joined = joinBookedLines(lines, assets); const rows = buildSiteSummaries(sites, invoices, joined, assets); + const year = latestInvoiceYear(invoices); const totalIncl = rows.reduce((s, r) => s + r.revenueIncl, 0); const totalExcl = rows.reduce((s, r) => s + r.revenueExcl, 0); const totalVat = rows.reduce((s, r) => s + r.vat, 0); const outstanding = rows.reduce((s, r) => s + r.unbookedCount, 0); - const pinned = rows.filter((r) => sites.find((x) => x.id === r.id)?.latitude).length; + const pinned = rows.filter((r) => r.pinned).length; return (
@@ -78,114 +79,7 @@ export default async function SitesPage() {

) : ( -
-
- - - - - - - - - - - - - - {rows.map((r) => ( - - - - - - - - - - ))} - - - - - - - - - -
- Site - - Invoices - - Machines - Revenue ex VAT - VAT - Total incl VATUnbooked
- - - {r.name} - {sites.find((x) => x.id === r.id)?.latitude ? ( - - ) : ( - no pin - )} - - - - {r.invoices.length} - - {r.machineCount} - - {ZAR2(r.revenueExcl)} - - {ZAR2(r.vat)} - -
{ZAR2(r.revenueIncl)}
- {/* The ex-VAT/VAT split stays visible on mobile, but as two short - stacked lines — "161 367,50 + 24 205,13" on one line is wider - than the total it annotates, which pushed the row back over the - 375px viewport. */} -
-
ex {ZAR2(r.revenueExcl)}
-
VAT {ZAR2(r.vat)}
-
-
- {r.unbookedCount > 0 ? ( - - - {r.unbookedCount} - unbooked - - ) : ( - — - )} -
- Total - - - - {ZAR(totalExcl)} - - {ZAR(totalVat)} - - {ZAR(totalIncl)} - -
-
- -
+ )} ); diff --git a/components/InvoiceTable.tsx b/components/InvoiceTable.tsx index 8ddd8f2..42a3799 100644 --- a/components/InvoiceTable.tsx +++ b/components/InvoiceTable.tsx @@ -284,7 +284,13 @@ export function InvoiceTable({ lines, assets }: { lines: BookedLine[]; assets: A
- +
{(!l.isBooked || l.booked_site) && ( diff --git a/components/MachineCombobox.tsx b/components/MachineCombobox.tsx index 68da931..813dfc6 100644 --- a/components/MachineCombobox.tsx +++ b/components/MachineCombobox.tsx @@ -20,10 +20,17 @@ export function MachineCombobox({ lineId, value, machines, + lineCode, + lineDescription, }: { lineId: string; value: string; machines: Machine[]; + // The invoice's own words for this line. The register rarely uses them: an invoice + // says "CB22 2.2 TON Roller Vibration" where the asset is filed as "CAT 2,4TON RV", + // so searching on the register alone would never connect the two. + lineCode?: string; + lineDescription?: string; }) { const router = useRouter(); const [query, setQuery] = useState(""); @@ -63,8 +70,15 @@ export function MachineCombobox({ // Flattened, filtered list — the flat order is what the keyboard walks. const groups = useMemo(() => { const q = query.trim().toLowerCase(); - const hit = (m: Machine) => - !q || m.code.toLowerCase().includes(q) || m.name.toLowerCase().includes(q) || m.category.toLowerCase().includes(q); + // Anything the invoice calls this line is searchable too, so "skidsteer" or + // "CB22" finds the machine even though neither word is in the register's name. + const extra = [lineCode, lineDescription].filter(Boolean).join(" ").toLowerCase(); + const hit = (m: Machine) => { + if (!q) return true; + if (m.code.toLowerCase().includes(q) || m.name.toLowerCase().includes(q) || m.category.toLowerCase().includes(q)) + return true; + return extra.length > 0 && extra.includes(q); + }; const matched = machines.filter(hit); const byCategory = new Map(); for (const m of matched) { @@ -82,7 +96,7 @@ export function MachineCombobox({ .flat() .sort((a, b) => a.code.localeCompare(b.code)), }; - }, [machines, query]); + }, [machines, query, lineCode, lineDescription]); const menuWidth = 300; // ~16px count strip + a "Not booked" row (if any) + 12px per row, capped. @@ -228,7 +242,7 @@ export function MachineCombobox({ >
{groups.flat.length} of {machines.length} machines - {!selected && " · type a code or name to narrow it"} + {!selected && " · search a code, name or the invoice description"}
{value && ( diff --git a/components/SitesView.tsx b/components/SitesView.tsx new file mode 100644 index 0000000..ac80e9f --- /dev/null +++ b/components/SitesView.tsx @@ -0,0 +1,337 @@ +"use client"; + +import { Fragment, useState } from "react"; +import Link from "next/link"; +import { AlertTriangle, ChevronRight, FileText, MapPin, MapPinned } from "lucide-react"; +import { Sparkline } from "@/components/Sparkline"; +import { ScrollFade } from "@/components/ScrollFade"; +import { MONTH_NAMES } from "@/lib/data"; +import type { SiteMonth, SiteSummary } from "@/lib/sites"; +import { ZAR, ZAR2 } from "@/lib/utils"; + +// Site · Billed · year/sparkline · Invoices · Machines · ex VAT · VAT · incl VAT · +// Unbooked · expand. Must equal the widest row, or the expanded panel lands a column +// short and the whole table shifts. +const COLS = 10; + +// The per-site equivalent of IncomeView's machine cards: a sparkline of what each +// month was billed, and on click the invoices behind each month. +// +// The month a figure sits under is the invoice's `booked_month`, the same field the +// seed posts into monthly_income — so a site's Sept total here is the same rand as the +// Sept column on /income. Deriving the month any other way would make the two screens +// quietly disagree, which is worse than having no site chart at all. +export function SitesView({ + rows, + year, + color = "#00798C", +}: { + rows: SiteSummary[]; + year: number; + color?: string; +}) { + const [expanded, setExpanded] = useState(null); + + return ( +
+
+ + + + + + + + + + + + + + + + {rows.map((r) => { + const isOpen = expanded === r.id; + const billed = r.monthly.filter((m) => m.year === year).length; + return ( + + + + + + + + + + + + + + {isOpen && ( + + + + )} + + ); + })} + + + + + + + + + +
+ Site + + Billed + + {year} + + Invoices + + Machines + + Revenue ex VAT + + VAT + + Total incl VAT + Unbooked +
+ + + {r.name} + {r.pinned ? ( + + ) : ( + no pin + )} + + {/* The sparkline's own column is hidden on mobile, so it rides under + the name here instead — same pattern as IncomeView's second row. + Two separate elements rather than one responsive width, so the + chart never reflows on resize. */} +
+ +
+
+ {billed} of 12 + months + + + + {r.invoices.length} + + {r.machineCount} + + {ZAR2(r.revenueExcl)} + + {ZAR2(r.vat)} + +
{ZAR2(r.revenueIncl)}
+ {/* The ex-VAT/VAT split stays visible on mobile, but as two short + stacked lines — "161 367,50 + 24 205,13" on one line is wider + than the total it annotates, which pushed the row back over the + 375px viewport. */} +
+
ex {ZAR2(r.revenueExcl)}
+
VAT {ZAR2(r.vat)}
+
+
+ {r.unbookedCount > 0 ? ( + + + {r.unbookedCount} + unbooked + + ) : ( + — + )} + + +
+ +
+ Total + + + + + + {ZAR(rows.reduce((s, r) => s + r.revenueExcl, 0))} + + {ZAR(rows.reduce((s, r) => s + r.vat, 0))} + + {ZAR(rows.reduce((s, r) => s + r.revenueIncl, 0))} + + +
+
+ +
+ ); +} + +function MonthPanel({ site, year, color }: { site: SiteSummary; year: number; color: string }) { + const inYear = site.monthly.filter((m) => m.year === year); + const billed = inYear.length; + const other = site.monthly.filter((m) => m.year !== year); + + return ( +
+
+

+ {site.name} — invoices per month +

+ + {billed} of 12 months billed in {year} + +
+ + {/* With one month of history a bare card looks like a bug, so say so plainly + rather than leaving the reader to guess. */} + {billed <= 1 && ( +

+ {billed === 0 + ? `No invoice for this site in ${year}.` + : `One month of billing so far. The trend fills in as each month's invoice lands.`} +

+ )} + + {inYear.length > 0 ? ( +
+ {inYear.map((m) => ( + + ))} +
+ ) : ( +

Nothing billed in {year}.

+ )} + + {other.length > 0 && ( +

+ Also billed in {other.map((m) => m.year).filter((v, i, a) => a.indexOf(v) === i).join(", ")} — not on this + chart, which shows {year}. +

+ )} + +
+ + + + +
+

+ + Each point is what this site was billed that month, VAT included — the same rand as the + month column on the income screen. +

+
+ ); +} + +function MonthCard({ month }: { month: SiteMonth }) { + return ( +
+
+
+
+ {MONTH_NAMES[month.month - 1]} {month.year} +
+
+ {month.invoices.length} invoice{month.invoices.length === 1 ? "" : "s"} +
+
+
+ {ZAR2(month.totalIncl)} +
+
+
+ ex {ZAR2(month.totalExcl)} + {ZAR2(month.vat)} VAT +
+
    + {month.invoices.map((inv) => ( +
  • + + + {inv.invoiceNo} + + + {fmtDate(inv.invoiceDate)} · {fmtRange(inv.periodFrom, inv.periodTo)} + + + {inv.hasPdf ? ( + + + PDF + + ) : ( + no PDF + )} +
  • + ))} +
+
+ ); +} + +function Stat({ label, value, strong }: { label: string; value: string; strong?: boolean }) { + return ( +
+
{label}
+
+ {value} +
+
+ ); +} + +// PocketBase dates come back as "2026-09-18 00:00:00.000Z". Parse the date part as UTC +// so the label can't slip a day backwards in a timezone behind the server. +function fmtDate(d: string): string { + if (!d) return "—"; + const dt = new Date(d.length <= 10 ? `${d}T00:00:00Z` : d); + if (Number.isNaN(dt.getTime())) return String(d).slice(0, 10); + return `${dt.getUTCDate()} ${MONTH_NAMES[dt.getUTCMonth()]}`; +} + +function fmtRange(from: string, to: string): string { + if (!from || !to) return "—"; + return `${fmtDate(from)} → ${fmtDate(to)}`; +} diff --git a/components/Sparkline.tsx b/components/Sparkline.tsx index 3d40101..27ddd58 100644 --- a/components/Sparkline.tsx +++ b/components/Sparkline.tsx @@ -6,9 +6,12 @@ type Props = { data: (number | null)[]; color: string; height?: number; + // Sites renders a narrower sparkline on mobile so the 375px table row keeps its + // budget. Defaults to the width /income has always used. + width?: number; }; -export function Sparkline({ data, color, height = 36 }: Props) { +export function Sparkline({ data, color, height = 36, width = 120 }: Props) { const points = data.map((v, i) => ({ i, v: v ?? 0 })); const max = Math.max(0.001, ...data.filter((v): v is number => v != null)); // Pad values: if all 0, draw a flat line at 0 for visual presence @@ -17,11 +20,14 @@ export function Sparkline({ data, color, height = 36 }: Props) { const displayMax = flat ? 1 : max; return ( -
+
- + {/* Keyed by colour AND width: /sites and /income can both mount a sparkline + with the same colour in one document, and identical ids would make the + second one reuse the first one's gradient. */} + @@ -31,7 +37,7 @@ export function Sparkline({ data, color, height = 36 }: Props) { dataKey="v" stroke={color} strokeWidth={1.5} - fill={`url(#spark-${color.replace("#", "")})`} + fill={`url(#spark-${color.replace("#", "")}-${width})`} isAnimationActive={false} baseValue={0} /> diff --git a/lib/sites.ts b/lib/sites.ts index ff2d6a2..2fff7de 100644 --- a/lib/sites.ts +++ b/lib/sites.ts @@ -23,6 +23,30 @@ export async function syncSitesFromInvoices(pb: any, invoices: Invoice[]): Promi return byName.size ? Array.from(byName.values()) : existing; } +export type SiteInvoiceRef = { + id: string; + invoiceNo: string; + invoiceDate: string; + periodFrom: string; + periodTo: string; + /** This site's share of the invoice — the sum of the site's lines, not the header + * total. Equal to the header total today, because an invoice carries one site, but + * summing lines keeps it right if an invoice ever spans two. */ + totalIncl: number; + hasPdf: boolean; +}; + +/** One month of billing for a site. A month holds one or more invoices. */ +export type SiteMonth = { + key: string; // "2026-09" + year: number; + month: number; // 1-12 + totalExcl: number; + vat: number; + totalIncl: number; + invoices: SiteInvoiceRef[]; +}; + export type SiteSummary = { id: string; name: string; @@ -32,13 +56,50 @@ export type SiteSummary = { /** What the debtors were billed in total. */ revenueIncl: number; invoices: { id: string; invoiceNo: string; invoiceDate: string; totalIncl: number }[]; + /** The calendar year the `months` series covers, so the chart is never ambiguous. */ + year: number; + /** 12 slots of billed amount incl VAT. null = no invoice that month, which is + * different from a billed zero. Same convention as IncomeRow.months. */ + months: (number | null)[]; + /** Only the months that actually have an invoice, ascending. Drives the expanded + * panel, so a month with no billing is simply absent rather than a zero row. */ + monthly: SiteMonth[]; machineCount: number; lineCount: number; + /** True once the site has map coordinates. Carried on the summary so the table — + * a client component — can render the marker without also receiving the site list. */ + pinned: boolean; /** Lines with neither a plant item nor a site — the work still outstanding. */ unbookedCount: number; unbookedAmount: number; }; +/** The year the chart should show: the most recent one any invoice was booked into. + * Scrolling forward a year re-bases every series without a schema change. */ +export function latestInvoiceYear(invoices: Invoice[]): number { + return invoices.reduce((y, i) => (i.booked_year > y ? i.booked_year : y), new Date().getUTCFullYear()); +} + +// Which month an invoice is billed in. +// +// `booked_month`/`booked_year` are already written by the seed from the month of +// `period_to` (scripts/seed.mjs), which is the same rule that posts the income into +// monthly_income. Reusing that field is what keeps a site's month here equal to the +// same month on /income — a site chart that disagreed with the income screen would be +// worse than no chart. The date fallback only covers a record imported before those +// fields existed. +function invoiceMonth(inv: Invoice): { year: number; month: number } { + if (inv.booked_month >= 1 && inv.booked_month <= 12 && inv.booked_year) { + return { year: inv.booked_year, month: inv.booked_month }; + } + const d = inv.period_to || inv.invoice_date; + if (d) { + const dt = new Date(d); + if (!Number.isNaN(dt.getTime())) return { year: dt.getUTCFullYear(), month: dt.getUTCMonth() + 1 }; + } + return { year: 0, month: 0 }; +} + // Per-site revenue and VAT, derived by walking invoice lines through their invoice. export function buildSiteSummaries( sites: Site[], @@ -47,7 +108,7 @@ export function buildSiteSummaries( assets: Asset[], ): SiteSummary[] { const invById = new Map(invoices.map((i) => [i.id, i])); - const assetById = new Map(assets.map((a) => [a.id, a])); + const year = latestInvoiceYear(invoices); return sites .map((site) => { @@ -62,6 +123,56 @@ export function buildSiteSummaries( if (inv) invMap.set(inv.id, { id: inv.id, invoiceNo: inv.invoice_no, invoiceDate: inv.invoice_date, totalIncl: inv.total_incl }); } + // Bucket the same lines by billing month. One invoice contributes several lines to + // a site (PHI 166 has five), so an invoice is listed ONCE per month and its amount + // accumulates across those lines — otherwise a site would show a card per line. + const buckets = new Map(); + const refsByMonth = new Map>(); + for (const l of siteLines) { + const inv = invById.get(l.invoice); + if (!inv) continue; + const m = invoiceMonth(inv); + if (!m.year || !m.month) continue; + const key = `${m.year}-${String(m.month).padStart(2, "0")}`; + let bucket = buckets.get(key); + if (!bucket) { + bucket = { key, year: m.year, month: m.month, totalExcl: 0, vat: 0, totalIncl: 0, invoices: [] }; + buckets.set(key, bucket); + refsByMonth.set(key, new Map()); + } + bucket.totalExcl += l.amount_excl || 0; + bucket.vat += l.vat || 0; + bucket.totalIncl += l.total_incl || 0; + + const refs = refsByMonth.get(key)!; + const seen = refs.get(inv.id); + if (seen) { + seen.totalIncl += l.total_incl || 0; + } else { + refs.set(inv.id, { + id: inv.id, + invoiceNo: inv.invoice_no, + invoiceDate: inv.invoice_date, + periodFrom: inv.period_from, + periodTo: inv.period_to, + totalIncl: l.total_incl || 0, + hasPdf: Boolean(inv.source_pdf), + }); + } + } + + const monthly = Array.from(buckets.values()) + .sort((a, b) => a.key.localeCompare(b.key)) + .map((b) => ({ + ...b, + invoices: Array.from(refsByMonth.get(b.key)!.values()).sort((x, y) => x.invoiceNo.localeCompare(y.invoiceNo)), + })); + + // 12-slot series for the sparkline. Months from OTHER years are dropped rather + // than folded in — a Dec-2025 invoice must not paint a point on Jan. + const months: (number | null)[] = Array(12).fill(null); + for (const b of monthly) if (b.year === year) months[b.month - 1] = b.totalIncl; + const unbooked = siteLines.filter((l) => !l.isBooked && !l.booked_site); return { id: site.id, @@ -70,8 +181,12 @@ export function buildSiteSummaries( vat, revenueIncl: incl, invoices: Array.from(invMap.values()).sort((a, b) => b.invoiceDate.localeCompare(a.invoiceDate)), + year, + months, + monthly, machineCount: new Set(siteLines.map((l) => l.booked_asset).filter(Boolean)).size, lineCount: siteLines.length, + pinned: Boolean(site.latitude), unbookedCount: unbooked.length, unbookedAmount: unbooked.reduce((s, l) => s + (l.total_incl || 0), 0), };