diff --git a/AGENTS.md b/AGENTS.md index d7584d1..ec507d5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,89 @@ If a route fails any of the above, it is not done. - `npm run build` → 15 routes, webpack mode (PWA is webpack-only) - `npm run typecheck` → `npx tsc --noEmit` - `npm run seed` → wipe + re-import `data/source/*.json` into PocketBase. Fails loudly on any unmatched row or broken VAT. +- `node scripts/setup-invoice-collections.mjs` → create the `invoices` / `invoice_lines` collections (idempotent). +- `node scripts/book-invoice.mjs` → post invoice lines into `monthly_income` as additional rows. + +## Invoices — adding another one + +`scripts/seed.mjs` imports **every** `data/source/invoice-*.json` automatically, so a new +invoice is one file in that folder — no script edit. Each needs `invoice_no`, the period, +`vat_pct`, the header totals and a `lines` array. The seed re-checks the arithmetic on every +invoice and refuses any one that does not balance, while still seeding the others. + +⚠️ `npm run seed` **deletes and recreates the `invoices` rows**, so any PDF uploaded through +`/income/invoices` is lost on the next seed and must be re-attached. Copy the PDFs somewhere +durable first — they are not in the repo. + +`invoices.site` is a plain text label transcribed from the invoice's own description +(`PHI 166` → "West Park", `PHI 167` → "Halfway Gardens"). There is **no `sites` collection +yet** — build one when the client has sites to list, and back-fill `site` from this field. + +## Invoice booking — run order matters + +`npm run seed` **wipes `monthly_income`** and re-imports only the workbook, so invoice-derived +income is destroyed on every seed. Always: + +```bash +npm run seed && node scripts/book-invoice.mjs +``` + +Booking is idempotent: each posted row is stamped with `monthly_income.invoice_line`, and +re-running removes the prior invoice-derived rows before re-inserting. Rows with an empty +`invoice_line` are workbook data and are never touched. + +An invoice line with no `booked_asset` is **left unbooked and reported loudly** — never guessed +at. Assign it from `/income/invoices` (the **Booked to** column), or set `asset_code` in the +relevant `data/source/invoice-*.json` and re-run the pair above. + +## One machine, many sites — not a duplicate + +A machine hired to more than one site in the same month is **normal use, not a duplicate +billing**. Client ruling, 2026-10-01. Such a line is booked to the **same machine** as income, +and every hire accumulates on that one asset. + +- `MPLANT05` (SC01) → PHI 166 West Park **and** PHI 171 Cosmo City, same period. +- `SS-01-CASE` (SS01) → PHI 167 Halfway Gardens **and** PHI 171 Cosmo City, same period. + +`findDuplicateMachineCodes` still finds these overlaps, but the result is **informational, not +a problem**: it must never tint a row red, badge it as unbooked, or block booking. The income +figure's hover/tap breakdown shows each machine's full hire history across sites, which is +where a multi-site machine should be read. Only an **unbooked** line is a real problem. + +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. + +## Money on invoices — VAT is NOT revenue + +On `PHI 166` the totals block reads `SUB TOTAL 161,367.50 / Escalation 0.00 / PLUS VAT +24,205.13 / INVOICE TOTAL 185,572.63`. The R24,205.13 sits under the column headed **PLUS +VAT** — it is 15% VAT, *not* an escalation, and escalation is R0.00 on every line. + +- The **invoice total (`total_incl`) is VAT-inclusive** — that is the headline figure. +- `total_excl` (hire) + `vat` = `total_incl`, checked per line and on the header by the seed. +- **Booked amount follows `lib/invoice-booking-policy.json` → `bookInclusiveVat`.** It is + currently `true`: amounts are booked VAT-**inclusive**, per the client's ruling of + 2026-10-01. That means SARS output VAT of R29,935.13 (Sept 2026 set) sits **inside** the + R229,502.63 shown as income on `/income`, so those figures are a cash/billing view, not a + VAT-excl. P&L. Flip the flag to `false` and re-run `npm run seed && node scripts/book-invoice.mjs` + to book ex-VAT instead. Both the app and the CLI script read that one file, so they + cannot disagree. +- The VAT stays visible rather than hidden: `/income` shows "includes R 29 935 VAT" and each + machine card shows "incl R X VAT" (`IncomeRow.invoiceVat`). Do not collapse the two. + +Extract these by coordinate (`pdfplumber`, x-positions) or read the rendered page visually. +Plain text extraction reorders these columns and silently turns VAT into escalation. + +## Manual invoice actions + +- `POST /api/invoice-lines/:id/book` body `{ assetId }` (`""` clears) — sets `booked_asset` + and **moves** the `monthly_income` row to the new machine rather than adding a second one. +- `POST /api/invoices/upload` multipart `file` + `invoiceNo` — PDF only, 20 MB cap, stored on + `invoices.source_pdf`. +- `GET /api/invoices/:id/document` — proxies the PDF. Do **not** link to + `NEXT_PUBLIC_PB_URL/api/files/`: the `invoices` collection is rule-locked and + that path 404s in a browser. PocketBase also resolves protected files by the full + `/api/files///` path — build it with `pb.files.getURL()`. ## Color palette — never change @@ -50,5 +133,7 @@ Chart marks (deeper steps of the same hues — see HANDOFF-PROMPT-3 §A.4): - URL: `process.env.NEXT_PUBLIC_PB_URL` (env or `.env.local`) - Auth: `pb.collection("_superusers").authWithPassword(env)` (Round 1 §5.3) -- Collections: `assets`, `monthly_income`, `monthly_running_costs`, `overheads`, `cost_lines` +- Collections: `assets`, `monthly_income`, `monthly_running_costs`, `overheads`, `cost_lines`, `invoices`, `invoice_lines` - `monthly_income` no longer carries `running_cost` — derive from `monthly_running_costs` at read time +- `monthly_income.invoice_line` → `invoice_lines`, nullable. Empty = workbook row, set = invoice-derived row +- `invoice_lines.booked_asset` → `assets`, nullable. Empty = line not yet booked to a machine diff --git a/app/api/invoice-lines/[id]/book/route.ts b/app/api/invoice-lines/[id]/book/route.ts new file mode 100644 index 0000000..62c1752 --- /dev/null +++ b/app/api/invoice-lines/[id]/book/route.ts @@ -0,0 +1,105 @@ +import { revalidatePath } from "next/cache"; +import { serverAuth } from "@/lib/pb"; +import { bookedAmount, BOOKED_LABEL } from "@/lib/invoice-booking"; + +export const dynamic = "force-dynamic"; + +// POST /api/invoice-lines/:id/book +// Body: { assetId: string } — "" clears the booking. +// +// Sets invoice_lines.booked_asset and keeps the matching monthly_income row in step: +// the previous invoice-derived row for this line is removed first, then a fresh one is +// posted against the new machine. That makes changing a booking safe and repeatable. +// +// The posted amount follows the shared policy in lib/invoice-booking-policy.json, so +// this route and scripts/book-invoice.mjs can never disagree about it. + +export async function POST( + req: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params; + + let assetId = ""; + try { + const body = await req.json(); + if (typeof body?.assetId !== "string") { + return Response.json({ ok: false, error: "assetId must be a string" }, { status: 400 }); + } + assetId = body.assetId.trim(); + } catch { + return Response.json({ ok: false, error: "Invalid JSON body" }, { status: 400 }); + } + + const pb = await serverAuth(); + + try { + const line = await pb.collection("invoice_lines").getOne(id); + const invoice = await pb.collection("invoices").getOne(line.invoice); + + // Verify the target asset actually exists before writing anything, so a stale + // dropdown value fails here rather than creating a dangling relation. + let asset = null; + if (assetId) { + try { + asset = await pb.collection("assets").getOne(assetId); + } catch { + return Response.json( + { ok: false, error: "That plant item no longer exists — reload the page" }, + { status: 400 }, + ); + } + } + + // Drop the row this line previously contributed, if any. + const existing = await pb.collection("monthly_income").getFullList({ + filter: `invoice_line = "${id}"`, + }); + for (const row of existing) await pb.collection("monthly_income").delete(row.id); + + await pb.collection("invoice_lines").update(id, { + booked_asset: assetId, + booked_year: line.booked_year || invoice.booked_year, + booked_month: line.booked_month || invoice.booked_month, + }); + + const amount = bookedAmount(line.amount_excl, line.vat); + + let posted = null; + if (asset) { + posted = await pb.collection("monthly_income").create({ + asset: assetId, + year: line.booked_year || invoice.booked_year, + month: line.booked_month || invoice.booked_month, + income: amount, + fixed_expenses: 0, + is_forecast: false, + invoice_line: id, + }); + } + + // Re-evaluate the header: booked only when every one of its lines has a target. + const siblings = await pb.collection("invoice_lines").getFullList({ + filter: `invoice = "${line.invoice}"`, + }); + const complete = siblings.every((l) => (l.id === id ? assetId : l.booked_asset)); + await pb.collection("invoices").update(line.invoice, { is_booked: complete }); + + revalidatePath("/income/invoices"); + revalidatePath("/income"); + + return Response.json({ + ok: true, + booked: Boolean(asset), + asset: asset ? { id: asset.id, code: asset.code, name: asset.name } : null, + income: posted?.income ?? 0, + label: BOOKED_LABEL, + invoiceFullyBooked: complete, + }); + } catch (e: any) { + return Response.json( + { ok: false, error: e?.message || "Booking failed" }, + { status: 500 }, + ); + } +} \ No newline at end of file diff --git a/app/api/invoices/[id]/document/route.ts b/app/api/invoices/[id]/document/route.ts new file mode 100644 index 0000000..cfae6ff --- /dev/null +++ b/app/api/invoices/[id]/document/route.ts @@ -0,0 +1,57 @@ +import { serverAuth } from "@/lib/pb"; + +export const dynamic = "force-dynamic"; + +// GET /api/invoices/:id/document +// Streams the invoice's source PDF. +// +// This deliberately does NOT link straight at PocketBase. The `invoices` collection +// ships with locked-down rules, so `/api/files/` on the PB host 404s for anyone +// without a superuser token — the "Attached document" link would be dead in the browser. +// Fetching server-side with the admin credentials and proxying keeps PocketBase closed +// while still letting an authenticated user open the document. + +export async function GET( + _req: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params; + + try { + const pb = await serverAuth(); + const invoice = await pb.collection("invoices").getOne(id); + const token = invoice.source_pdf; + 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); + + const res = await fetch(upstream, { + headers: pb.authStore.token ? { Authorization: pb.authStore.token } : undefined, + }); + if (!res.ok) { + return Response.json( + { ok: false, error: "Document could not be read from storage" }, + { status: 502 }, + ); + } + + return new Response(res.body, { + status: 200, + headers: { + "Content-Type": res.headers.get("Content-Type") ?? "application/pdf", + "Content-Disposition": `inline; filename="${invoice.invoice_no}.pdf"`, + "Cache-Control": "private, max-age=0, must-revalidate", + }, + }); + } catch (e: any) { + return Response.json( + { ok: false, error: e?.message || "Could not load document" }, + { status: 500 }, + ); + } +} \ No newline at end of file diff --git a/app/api/invoices/upload/route.ts b/app/api/invoices/upload/route.ts new file mode 100644 index 0000000..3e8cff0 --- /dev/null +++ b/app/api/invoices/upload/route.ts @@ -0,0 +1,98 @@ +import { revalidatePath } from "next/cache"; +import { serverAuth } from "@/lib/pb"; + +export const dynamic = "force-dynamic"; + +// POST /api/invoices/upload +// Body: multipart/form-data with `file` and `invoiceNo`. +// +// Stores the original document against the invoice's `source_pdf` file field so the +// source of every extracted figure stays attached to the row it came from. +// +// Accepts PDF only — an invoice in any other format cannot have its line items verified +// against the PDF totals, and accepting it silently would break that guarantee. + +const MAX_BYTES = 20 * 1024 * 1024; // matches the PocketBase field's maxSize + +export async function POST(req: Request) { + let form: FormData; + try { + form = await req.formData(); + } catch { + return Response.json({ ok: false, error: "Expected multipart/form-data" }, { status: 400 }); + } + + const file = form.get("file"); + const invoiceNo = String(form.get("invoiceNo") ?? "").trim(); + + if (!(file instanceof File) || file.size === 0) { + return Response.json({ ok: false, error: "No file received" }, { status: 400 }); + } + if (file.type !== "application/pdf" && !file.name.toLowerCase().endsWith(".pdf")) { + return Response.json( + { ok: false, error: "Only PDF invoices are accepted" }, + { status: 400 }, + ); + } + if (file.size > MAX_BYTES) { + return Response.json( + { ok: false, error: `File is ${(file.size / 1048576).toFixed(1)} MB — limit is 20 MB` }, + { status: 400 }, + ); + } + + const pb = await serverAuth(); + + try { + // Resolve the invoice by number; fall back to the only one present. + let target = null; + if (invoiceNo) { + const found = await pb.collection("invoices").getFullList({ + filter: `invoice_no = "${invoiceNo.replace(/"/g, "")}"`, + perPage: 1, + }); + target = found[0] ?? null; + if (!target) { + return Response.json( + { ok: false, error: `No invoice found with number "${invoiceNo}"` }, + { status: 404 }, + ); + } + } else { + const all = await pb.collection("invoices").getFullList({ perPage: 2 }); + if (all.length === 0) { + return Response.json({ ok: false, error: "No invoices exist yet" }, { status: 404 }); + } + if (all.length > 1) { + return Response.json( + { ok: false, error: "More than one invoice — specify which by number" }, + { status: 400 }, + ); + } + target = all[0]; + } + + const safeName = file.name.replace(/[^\w.\- ]+/g, "_").slice(-120); + const form2 = new FormData(); + form2.append("source_pdf", new File([await file.arrayBuffer()], safeName, { type: "application/pdf" })); + + const updated = await pb.collection("invoices").update(target.id, form2); + + revalidatePath("/income/invoices"); + + const stored = updated.source_pdf as string | undefined; + return Response.json({ + ok: true, + invoice_no: target.invoice_no, + file: safeName, + size: file.size, + // PocketBase returns "filename" or "filename_abcd1234"; the base name is the file. + path: stored?.split("/").pop() ?? null, + }); + } catch (e: any) { + return Response.json( + { ok: false, error: e?.message || "Upload failed" }, + { status: 500 }, + ); + } +} \ No newline at end of file diff --git a/app/income/invoices/page.tsx b/app/income/invoices/page.tsx new file mode 100644 index 0000000..bfa2ecd --- /dev/null +++ b/app/income/invoices/page.tsx @@ -0,0 +1,63 @@ +import { + getAssets, getInvoices, getInvoiceLines, joinBookedLines, + findDuplicateMachineCodes, +} from "@/lib/data"; +import { FileText } from "lucide-react"; +import { InvoiceList, EmptyInvoices } from "@/components/InvoiceList"; + +// Reads PocketBase on every request, same as /income — see the note in app/income/page.tsx. +export const dynamic = "force-dynamic"; + +export default async function InvoicesPage() { + const [invoices, lines, assets] = await Promise.all([ + getInvoices(), + getInvoiceLines(), + getAssets(), + ]); + + // Group the lines per invoice once, so the client table gets plain data rather than + // making it re-derive the grouping on every render. + const linesByInvoice: Record> = {}; + const joined = joinBookedLines(lines, assets); + for (const l of joined) { + (linesByInvoice[l.invoice] ||= []).push(l); + } + + const unbookedCount = joined.filter((l) => !l.isBooked).length; + const multiSite = findDuplicateMachineCodes(invoices, joined); + + return ( +
+
+
+
+
+ +
+

Invoices

+
+

+ One row per invoice · click to open the line items and book each to a plant item +

+
+
+ + {invoices.length === 0 ? ( + + ) : ( + + )} + + {unbookedCount > 0 && ( +

+ {unbookedCount} line{unbookedCount > 1 ? "s" : ""} awaiting a target machine. Rows flagged{" "} + unbooked above — open one and pick a plant + item in the Booked to column + and it posts to that machine's income immediately. To make it the default, set the asset in{" "} + data/source/invoice-*.json and re-run{" "} + npm run seed. +

+ )} +
+ ); +} \ No newline at end of file diff --git a/app/income/page.tsx b/app/income/page.tsx index 8a37c06..6dbd564 100644 --- a/app/income/page.tsx +++ b/app/income/page.tsx @@ -1,4 +1,4 @@ -import { getAssets, getActualMonthlyIncome, getRunningCosts } from "@/lib/data"; +import { getAssets, getActualMonthlyIncome, getRunningCosts, getInvoiceLines } from "@/lib/data"; import { ZAR } from "@/lib/utils"; import { TrendingUp } from "lucide-react"; import { IncomeView } from "@/components/IncomeView"; @@ -10,14 +10,16 @@ import { buildIncomeRows } from "@/lib/rows"; export const dynamic = "force-dynamic"; export default async function IncomePage() { - const [assets, incomeActual, running] = await Promise.all([ + const [assets, incomeActual, running, invoiceLines] = await Promise.all([ getAssets(), getActualMonthlyIncome(), getRunningCosts(), + getInvoiceLines(), ]); - // Part 1.2: YTD figures use actuals only. - const rows = buildIncomeRows(assets, incomeActual, running); + // Part 1.2: YTD figures use actuals only. The invoice lines let each machine's figure + // show how much of it is invoice income and how much of that is VAT. + const rows = buildIncomeRows(assets, incomeActual, running, {}, invoiceLines); const totalIncome = rows.reduce((s, m) => s + m.income, 0); const totalProfit = rows.reduce((s, m) => s + m.ytdProfit, 0); diff --git a/app/layout.tsx b/app/layout.tsx index 3fc6b47..c235348 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -48,7 +48,15 @@ export const metadata: Metadata = { const navItems: NavItem[] = [ { kind: "link", href: "/", label: "Dashboard", icon: }, { kind: "link", href: "/plant", label: "Plant Register", icon: }, - { kind: "link", href: "/income", label: "Income", icon: }, + { + kind: "group", + label: "Income", + icon: , + children: [ + { href: "/income", label: "Overview" }, + { href: "/income/invoices", label: "Invoices" }, + ], + }, { kind: "link", href: "/running", label: "Running Costs", icon: }, { kind: "group", diff --git a/components/BookingSelect.tsx b/components/BookingSelect.tsx new file mode 100644 index 0000000..5cfd206 --- /dev/null +++ b/components/BookingSelect.tsx @@ -0,0 +1,105 @@ +"use client"; + +import { useState, useMemo } from "react"; +import { useRouter } from "next/navigation"; +import { Loader2, AlertCircle } from "lucide-react"; + +type AssetOption = { id: string; code: string; name: string; category: string }; + +// Books one invoice line to a plant item from the register. +// +// Saves immediately on change — there is no separate "save" button to miss on a phone. +// On success it refreshes the router so the server-rendered header, banner and totals +// re-read from PocketBase rather than showing stale booking state. +export function BookingSelect({ + lineId, + value, + assets, +}: { + lineId: string; + value: string; + assets: AssetOption[]; +}) { + const router = useRouter(); + const [current, setCurrent] = useState(value); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + // 44 plant items flat in one dropdown is unusable — group them by category. + const groups = useMemo(() => { + const byCategory = new Map(); + for (const a of assets) { + const key = a.category || "Other"; + if (!byCategory.has(key)) byCategory.set(key, []); + byCategory.get(key)!.push(a); + } + return Array.from(byCategory.entries()) + .map(([category, items]) => ({ category, items: items.sort((x, y) => x.code.localeCompare(y.code)) })) + .sort((a, b) => a.category.localeCompare(b.category)); + }, [assets]); + + async function book(assetId: string) { + const previous = current; + setSaving(true); + setError(null); + setCurrent(assetId); // optimistic + try { + const res = await fetch(`/api/invoice-lines/${lineId}/book`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ assetId }), + }); + const data = await res.json().catch(() => ({ ok: false, error: "Unexpected response" })); + if (!data.ok) { + setCurrent(previous); // roll back so the UI never claims a booking that failed + setError(data.error || "Booking failed"); + } else { + router.refresh(); // re-read server-rendered banner/totals + } + } catch { + setCurrent(previous); + setError("Could not reach the server"); + } finally { + setSaving(false); + } + } + + return ( +
+
+ + + {saving && } +
+ {error && ( +

+ + {error} +

+ )} +
+ ); +} \ No newline at end of file diff --git a/components/IncomeView.tsx b/components/IncomeView.tsx index bb7a398..b24a51d 100644 --- a/components/IncomeView.tsx +++ b/components/IncomeView.tsx @@ -22,7 +22,11 @@ export function IncomeView({ rows, palette }: { rows: IncomeRow[]; palette: { in const totalIncome = rows.reduce((s, r) => s + r.income, 0); const totalProfit = rows.reduce((s, r) => s + r.ytdProfit, 0); const profitable = rows.filter((r) => r.ytdProfit > 0).length; - return { totalIncome, totalProfit, profitable }; + // VAT already inside the booked figures — booked amounts are VAT-inclusive, so this + // is part of the income total and is called out rather than hidden. + const invoiceVat = rows.reduce((s, r) => s + r.invoiceVat, 0); + const invoiceIncome = rows.reduce((s, r) => s + r.invoiceIncome, 0); + return { totalIncome, totalProfit, profitable, invoiceVat, invoiceIncome }; }, [rows]); return ( @@ -36,6 +40,11 @@ export function IncomeView({ rows, palette }: { rows: IncomeRow[]; palette: { in {rows.length - totals.profitable} loss-making + {totals.invoiceVat > 0 && ( + + includes {ZAR(totals.invoiceVat)} VAT + + )} · sorted by {sort} {/* Round 4 Part B.3: Sort collapses to a select on mobile. View toggle stays as buttons. */} @@ -153,7 +162,7 @@ function CardsView({
-
YTD income
+
YTD income
{ZAR2(r.income)}
@@ -175,7 +184,7 @@ function CardsView({
-
YTD income
+
YTD income
{ZAR2(r.income)}
{r.months.map((v, i) => (
-
{MONTH_NAMES[i]}
-
0 ? "bg-[#00798C]/5 text-[#00798C] dark:bg-[#00798C]/15" : "text-gray-300 dark:text-gray-600"}`}> +
{MONTH_NAMES[i]}
+
0 + ? "bg-[#00798C]/5 text-[#00798C] dark:bg-[#00798C]/15" + : "text-gray-300 dark:text-gray-600" + }`} + > {v == null ? "—" : (v > 0 ? ZAR2(v) : "R 0")}
@@ -220,7 +235,7 @@ function CardsView({ {positive ? "+" : ""}{ZAR(r.ytdProfit)}
-
+
)} @@ -275,6 +290,31 @@ function GridView({ rows }: { rows: IncomeRow[] }) { ); })} + {/* Totals row: the sum of every machine for each month. */} + + + + Total + + + All machines + + {MONTH_NAMES.map((m, i) => { + const sum = rows.reduce((s, r) => s + (r.months[i] ?? 0), 0); + return ( + + {sum > 0 ? ZAR(sum) : "—"} + + ); + })} + + {ZAR(rows.reduce((s, r) => s + r.income, 0))} + + + diff --git a/components/InvoiceList.tsx b/components/InvoiceList.tsx new file mode 100644 index 0000000..2412e19 --- /dev/null +++ b/components/InvoiceList.tsx @@ -0,0 +1,273 @@ +"use client"; + +import { useState } from "react"; +import { ChevronRight, AlertTriangle, CheckCircle2, MapPin, FileText, Repeat } from "lucide-react"; +import type { Asset, BookedLine, Invoice } from "@/lib/data"; +import { BOOK_INCLUSIVE_VAT, BOOKED_LONG_LABEL } from "@/lib/invoice-booking"; +import { ZAR, ZAR2 } from "@/lib/utils"; +import { bookedAmount } from "@/lib/invoice-booking"; +import { InvoiceSummary } from "@/components/InvoiceTable"; + +// One row per invoice. Clicking a row expands the full line-item detail underneath it, +// so the common case (scan for problems) is a single table read and the detail is opt-in. +// +// Anything wrong with an invoice — an unbooked line, no document attached — is surfaced +// on its own row, because a problem buried inside a collapsed detail is a problem you miss. +export function InvoiceList({ + invoices, + linesByInvoice, + assets, + multiSite, +}: { + invoices: Invoice[]; + linesByInvoice: Record; + assets: Asset[]; + // line id -> other invoice numbers billing the same machine over the same period. + // Informational only: a machine hired to two sites in a month is normal, not an error. + multiSite: Map; +}) { + const [open, setOpen] = useState(null); + + const allLines = Object.values(linesByInvoice).flat(); + const bookedLines = allLines.filter((l) => l.isBooked); + const sum = (ls: BookedLine[], k: "amount_excl" | "vat") => ls.reduce((s, l) => s + (l[k] || 0), 0); + const bookedExVat = sum(bookedLines, "amount_excl"); + const bookedVat = sum(bookedLines, "vat"); + + const totals = invoices.reduce( + (acc, i) => ({ + exVat: acc.exVat + (i.total_excl || 0), + vat: acc.vat + (i.vat || 0), + incl: acc.incl + (i.total_incl || 0), + }), + { exVat: 0, vat: 0, incl: 0 }, + ); + + return ( + <> +
+ + {/* What actually got booked against the machines, per the shared policy. */} +
+
{BOOKED_LONG_LABEL}
+
+ {ZAR2(bookedAmount(bookedExVat, bookedVat))} +
+
+ {BOOK_INCLUSIVE_VAT + ? `${ZAR2(bookedExVat)} ex VAT + ${ZAR2(bookedVat)} VAT` + : `${ZAR2(bookedVat)} VAT not counted as income`} +
+
+ {/* What the debtors are actually invoiced. */} + +
+ +
+ {/* Rule 1 + 4: scrolls inside its own container; page never scrolls sideways. */} +
+ + + + + + + + + + + + + + + {invoices.map((inv) => { + const lines = linesByInvoice[inv.id] ?? []; + const booked = lines.filter((l) => l.isBooked); + const unbooked = lines.filter((l) => !l.isBooked); + const shared = lines.filter((l) => multiSite.get(l.id)?.length); + // Only an unbooked line is a problem. A machine used at two sites in a + // month is expected, so it must not tint the row or badge it as an error. + const hasProblem = unbooked.length > 0; + const isOpen = open === inv.id; + const sum = (ls: BookedLine[], k: "amount_excl" | "vat") => + ls.reduce((s, l) => s + (l[k] || 0), 0); + const bookedExVat = sum(booked, "amount_excl"); + const bookedVat = sum(booked, "vat"); + const bookedTotal = bookedAmount(bookedExVat, bookedVat); + + return ( + ({ machineCode: l.bookedCode || l.machine_code, others: multiSite.get(l.id) ?? [] }))} + hasProblem={hasProblem} + isOpen={isOpen} + onToggle={() => setOpen(isOpen ? null : inv.id)} + expanded={ + isOpen ? ( +
+ +
+ ) : null + } + /> + ); + })} +
+
+ Invoice + SitePeriodLinesRevenue ex VATVATTotal incl VATStatus
+
+
+ + ); +} + +function InvoiceRow({ + invoice, lineCount, unbooked, bookedExVat, bookedVat, bookedTotal, shared, + hasProblem, isOpen, onToggle, expanded, +}: { + invoice: Invoice; + lineCount: number; + unbooked: number; + bookedExVat: number; + bookedVat: number; + bookedTotal: number; + shared: { machineCode: string; others: string[] }[]; + hasProblem: boolean; + isOpen: boolean; + onToggle: () => void; + expanded: React.ReactNode; +}) { + return ( + <> + + {/* Sticky first column so the invoice identity survives horizontal scroll. */} + + + + + {invoice.site ? ( + + + {invoice.site} + + ) : ( + — + )} + + + {invoice.period_from.slice(0, 10)} → {invoice.period_to.slice(0, 10)} + + {lineCount} + + {ZAR2(invoice.total_excl)} + + + {ZAR2(invoice.vat)} + + + {ZAR2(invoice.total_incl)} + + +
+ {hasProblem ? ( + + + {unbooked} unbooked + + ) : ( + + + All booked + + )} + {shared.length > 0 && ( + + + + {shared.length === 1 ? "1 machine" : `${shared.length} machines`} also used on{" "} + {[...new Set(shared.flatMap((d) => d.others))].join(", ")} + + + )} + + booked {ZAR2(bookedTotal)} + {BOOK_INCLUSIVE_VAT && bookedVat > 0 && <> · {ZAR2(bookedVat)} VAT} + +
+ + + + {isOpen && ( + + {/* Span the full table so the detail panel is not clipped by the sticky column. */} + + {expanded} + + + )} + + ); +} + +function Stat({ label, value, tone }: { label: string; value: string; tone: "teal" | "brand" }) { + if (tone === "brand") { + return ( +
+
{label}
+
{value}
+
+ ); + } + return ( +
+
{label}
+
{value}
+
+ ); +} + +// Kept so an empty folder still renders a usable page. +export function EmptyInvoices() { + return ( +
+ +

No invoices yet.

+

+ Add a data/source/invoice-*.json then run{" "} + npm run seed. +

+
+ ); +} + +export { ZAR }; \ No newline at end of file diff --git a/components/InvoiceTable.tsx b/components/InvoiceTable.tsx new file mode 100644 index 0000000..92f772c --- /dev/null +++ b/components/InvoiceTable.tsx @@ -0,0 +1,300 @@ +import type { Asset, BookedLine, Invoice } from "@/lib/data"; +import { ZAR2 } from "@/lib/utils"; +import { AlertTriangle, CheckCircle2 } from "lucide-react"; +import { BookingSelect } from "@/components/BookingSelect"; +import { InvoiceUploader, AttachedDocument } from "@/components/InvoiceUploader"; + +// Right-edge fade so a horizontally scrollable table reads as scrollable on touch. +// pointer-events-none so it never swallows a tap on the row underneath. +function ScrollFade() { + return ( +