mega-plant/components/InvoiceList.tsx
David baf0bddde6 Invoices: line-item extraction, booking to plant items, VAT-inclusive income
Adds an Invoices sub-menu under /income with a summary table (one row per
invoice, click to expand the full detail) and the data model behind it.

Data:
- invoices + invoice_lines PocketBase collections, created idempotently by
  scripts/setup-invoice-collections.mjs
- line items transcribed from PHI 166/167/171 with per-invoice arithmetic
  validation in the seed; a bad invoice is refused without blocking the rest
- invoice-*.json in data/source are auto-imported, so adding an invoice is
  one file

Booking:
- manual book-in per line (Booked to), grouped by category
- POST /api/invoice-lines/:id/book moves the income row rather than adding a
  second one, so a booking can be changed freely
- scripts/book-invoice.mjs is idempotent: rows are stamped with
  monthly_income.invoice_line and replaced on re-run
- booked amount follows lib/invoice-booking-policy.json (bookInclusiveVat),
  read by both the app and the CLI so they cannot disagree

Money:
- the R24,205.13 column on these invoices is PLUS VAT, not escalation. The
  seed now rejects VAT that disagrees with the stated rate.
- booked amounts are VAT-inclusive per the client ruling of 2026-10-01; the
  VAT stays visible rather than hidden.

Uploads:
- drag-and-drop PDF attached to invoices.source_pdf
- GET /api/invoices/:id/document proxies it — the collection is rule-locked,
  so a direct PocketBase /api/files link 404s in a browser

Fixes a month-aggregation bug this introduced: buildIncomeRows assigned into
months[i], so a machine with two income rows in one month (one per invoice,
which is normal multi-site use) showed only the last. SS01 September read
R20,700 instead of R35,305 while its own YTD included both. Months now
accumulate, per-month profit is computed once after accumulation, and
lib/rows.test.ts pins it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-01 15:59:30 +02:00

273 lines
No EOL
12 KiB
TypeScript

"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<string, BookedLine[]>;
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<string, string[]>;
}) {
const [open, setOpen] = useState<string | null>(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 (
<>
<div className="mb-5 flex flex-wrap items-stretch gap-3">
<Stat label="Invoices" value={String(invoices.length)} tone="teal" />
{/* What actually got booked against the machines, per the shared policy. */}
<div className="px-5 py-3 rounded-2xl bg-[#00798C]/10 border border-[#00798C]/15">
<div className="text-xs text-[#00798C]">{BOOKED_LONG_LABEL}</div>
<div className="text-2xl font-semibold tabular-nums text-[#003D5B] dark:text-white">
{ZAR2(bookedAmount(bookedExVat, bookedVat))}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 tabular-nums">
{BOOK_INCLUSIVE_VAT
? `${ZAR2(bookedExVat)} ex VAT + ${ZAR2(bookedVat)} VAT`
: `${ZAR2(bookedVat)} VAT not counted as income`}
</div>
</div>
{/* What the debtors are actually invoiced. */}
<Stat label="Billed (incl VAT)" value={ZAR2(totals.incl)} tone="brand" />
</div>
<div className="bg-white dark:bg-[#131a21] rounded-2xl border border-gray-100 dark:border-white/5 shadow-sm overflow-hidden">
{/* Rule 1 + 4: scrolls inside its own container; page never scrolls sideways. */}
<div className="overflow-x-auto" style={{ WebkitOverflowScrolling: "touch" }}>
<table className="w-full text-sm min-w-[820px]">
<thead>
<tr className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider border-b border-gray-100 dark:border-white/5 bg-gray-50/50 dark:bg-white/[0.02]">
<th className="text-left py-3 px-4 font-medium sticky left-0 z-10 bg-gray-50/95 dark:bg-[#1a222b]/95 backdrop-blur">
Invoice
</th>
<th className="text-left py-3 px-3 font-medium whitespace-nowrap">Site</th>
<th className="text-left py-3 px-3 font-medium whitespace-nowrap">Period</th>
<th className="text-right py-3 px-3 font-medium whitespace-nowrap">Lines</th>
<th className="text-right py-3 px-3 font-medium whitespace-nowrap">Revenue ex VAT</th>
<th className="text-right py-3 px-3 font-medium whitespace-nowrap">VAT</th>
<th className="text-right py-3 px-3 font-medium whitespace-nowrap">Total incl VAT</th>
<th className="text-left py-3 px-4 font-medium whitespace-nowrap">Status</th>
</tr>
</thead>
<tbody>
{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 (
<InvoiceRow
key={inv.id}
invoice={inv}
lineCount={lines.length}
unbooked={unbooked.length}
bookedExVat={bookedExVat}
bookedVat={bookedVat}
bookedTotal={bookedTotal}
shared={shared.map((l) => ({ machineCode: l.bookedCode || l.machine_code, others: multiSite.get(l.id) ?? [] }))}
hasProblem={hasProblem}
isOpen={isOpen}
onToggle={() => setOpen(isOpen ? null : inv.id)}
expanded={
isOpen ? (
<div className="p-3 sm:p-4 bg-gray-50/60 dark:bg-white/[0.02]">
<InvoiceSummary invoice={inv} lines={lines} assets={assets} embedded />
</div>
) : null
}
/>
);
})}
</tbody>
</table>
</div>
</div>
</>
);
}
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 (
<>
<tr
className={`border-b border-gray-50 dark:border-white/5 transition ${
hasProblem ? "bg-[#D1495B]/[0.04]" : "hover:bg-gray-50/60 dark:hover:bg-white/[0.02]"
}`}
>
{/* Sticky first column so the invoice identity survives horizontal scroll. */}
<td
className={`py-2 px-4 sticky left-0 z-10 ${
hasProblem ? "bg-[#D1495B]/[0.04] dark:bg-[#1a1319]" : "bg-white dark:bg-[#131a21]"
}`}
>
<button
type="button"
onClick={onToggle}
aria-expanded={isOpen}
className="flex items-center gap-2 min-h-[44px] w-full text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-[#30638E] rounded-lg"
>
<ChevronRight
className={`w-4 h-4 text-gray-400 shrink-0 transition-transform ${isOpen ? "rotate-90" : ""}`}
/>
<span className="font-mono text-xs font-semibold text-[#00798C] whitespace-nowrap">
{invoice.invoice_no}
</span>
</button>
</td>
<td className="py-2 px-3 whitespace-nowrap">
{invoice.site ? (
<span className="inline-flex items-center gap-1.5 text-sm text-[#003D5B] dark:text-white">
<MapPin className="w-3.5 h-3.5 text-[#EDAE49] shrink-0" />
{invoice.site}
</span>
) : (
<span className="text-sm text-gray-400 dark:text-gray-500">—</span>
)}
</td>
<td className="py-2 px-3 text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap tabular-nums">
{invoice.period_from.slice(0, 10)} → {invoice.period_to.slice(0, 10)}
</td>
<td className="py-2 px-3 text-right tabular-nums text-gray-700 dark:text-gray-300">{lineCount}</td>
<td className="py-2 px-3 text-right tabular-nums font-medium text-[#003D5B] dark:text-white whitespace-nowrap">
{ZAR2(invoice.total_excl)}
</td>
<td className="py-2 px-3 text-right tabular-nums text-gray-500 dark:text-gray-400 whitespace-nowrap">
{ZAR2(invoice.vat)}
</td>
<td className="py-2 px-3 text-right tabular-nums font-semibold text-[#003D5B] dark:text-white whitespace-nowrap">
{ZAR2(invoice.total_incl)}
</td>
<td className="py-2 px-4 whitespace-nowrap">
<div className="flex flex-col gap-1">
{hasProblem ? (
<span className="inline-flex items-center gap-1.5 self-start px-2.5 py-1.5 rounded-lg bg-[#D1495B]/10 text-[#D1495B] text-xs font-semibold">
<AlertTriangle className="w-3.5 h-3.5 shrink-0" />
{unbooked} unbooked
</span>
) : (
<span className="inline-flex items-center gap-1.5 self-start px-2.5 py-1.5 rounded-lg bg-[#00798C]/10 text-[#00798C] text-xs font-semibold">
<CheckCircle2 className="w-3.5 h-3.5 shrink-0" />
All booked
</span>
)}
{shared.length > 0 && (
<span
title="The same machine hired to more than one site this month — normal, each hire books to that machine."
className="text-xs text-[#8a6420] dark:text-[#EDAE49] flex items-start gap-1"
>
<Repeat className="w-3 h-3 mt-0.5 shrink-0" />
<span>
{shared.length === 1 ? "1 machine" : `${shared.length} machines`} also used on{" "}
{[...new Set(shared.flatMap((d) => d.others))].join(", ")}
</span>
</span>
)}
<span className="text-xs text-gray-500 dark:text-gray-400 tabular-nums">
booked {ZAR2(bookedTotal)}
{BOOK_INCLUSIVE_VAT && bookedVat > 0 && <> · {ZAR2(bookedVat)} VAT</>}
</span>
</div>
</td>
</tr>
{isOpen && (
<tr>
{/* Span the full table so the detail panel is not clipped by the sticky column. */}
<td colSpan={8} className="p-0 border-b border-gray-100 dark:border-white/5">
{expanded}
</td>
</tr>
)}
</>
);
}
function Stat({ label, value, tone }: { label: string; value: string; tone: "teal" | "brand" }) {
if (tone === "brand") {
return (
<div className="bg-gradient-to-br from-[#00798C] to-[#003D5B] rounded-2xl px-5 py-3 text-white shadow-lg shadow-teal-500/20">
<div className="text-xs text-white/80">{label}</div>
<div className="text-2xl font-semibold tabular-nums">{value}</div>
</div>
);
}
return (
<div className="px-5 py-3 rounded-2xl bg-[#00798C]/10 border border-[#00798C]/15">
<div className="text-xs text-[#00798C]">{label}</div>
<div className="text-2xl font-semibold tabular-nums text-[#003D5B] dark:text-white">{value}</div>
</div>
);
}
// Kept so an empty folder still renders a usable page.
export function EmptyInvoices() {
return (
<div className="bg-white dark:bg-[#131a21] border border-gray-100 dark:border-white/5 rounded-2xl shadow-sm p-10 text-center">
<FileText className="w-8 h-8 mx-auto text-gray-300 dark:text-gray-600 mb-3" />
<p className="text-gray-500 dark:text-gray-400">No invoices yet.</p>
<p className="text-sm text-gray-400 dark:text-gray-500 mt-1">
Add a <code className="font-mono text-[#00798C]">data/source/invoice-*.json</code> then run{" "}
<code className="font-mono text-[#00798C]">npm run seed</code>.
</p>
</div>
);
}
export { ZAR };