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.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), };