"use client"; import { useEffect, useRef, useState } from "react"; import { ChevronRight, AlertTriangle, CheckCircle2, FileText } from "lucide-react"; import { SupplierCell, ReadOnlyName } from "@/components/SupplierCell"; 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"; import { ScrollFade } from "@/components/ScrollFade"; // 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, partyScope = "internal", }: { invoices: Invoice[]; linesByInvoice: Record; assets: Asset[]; /** Which half of the register this is. It decides what the second column means: a SITE * on the internal page, the SUPPLIER on the external one. The same `site` field backs * both, but the external name is typed in by a human — see components/SupplierCell. */ partyScope?: "internal" | "external"; }) { const [open, setOpen] = useState(null); // Names edited in place this session, keyed by invoice id. Held here so a supplier // rename shows the moment it saves instead of waiting for the server round trip to // replace this client tree. const [names, setNames] = useState>({}); const nameFor = (inv: Invoice) => names[inv.id] ?? inv.site ?? ""; // The expanded detail lives in a of THIS scrollable table, and it holds its own // wide line-item table. Without a width of its own, that inner table's min-width // propagates up and stretches the outer table to 1234px — so the whole list, sticky // column included, scrolled sideways whenever a row was open, and the two tables' // `sticky left-0` columns landed on top of each other. // // Pinning the detail to the scroller's measured width stops it influencing the outer // table at all: the outer scrolls only its own 820px, and the inner table scrolls // inside the detail with its own sticky column, which is now the only one in that // scroller. z-20 keeps the detail above the outer sticky cell (z-10). const scrollRef = useRef(null); const [detailW, setDetailW] = useState(null); useEffect(() => { const el = scrollRef.current; if (!el) return; const measure = () => setDetailW(el.clientWidth); measure(); const ro = new ResizeObserver(measure); ro.observe(el); return () => ro.disconnect(); }, []); 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 ( <> {/* TWO tiles, not three, and a grid rather than flex-wrap. Both reasons are the same lesson: "Booked" (summed over booked lines) and "Billed" (summed over every invoice header) are the SAME figure whenever every line is booked — which is the goal state — so two tiles showing one number read as a duplicate rather than as a reconciliation. The reading that carries information is "booked OF billed", so the single tile now says that, and the two figures separate on their own the moment a line is left unbooked. Equal columns, not content sizing: this tile is far wider than the invoice count beside it because of the "of … billed" line and the VAT breakdown, and flex would size by content and shove the second tile onto its own row well above the 640px where a tablet has plenty of room. */}
{/* What actually got booked against the machines, per the shared policy, set against what the debtors have actually been invoiced. */}
{BOOKED_LONG_LABEL}
{ZAR2(bookedAmount(bookedExVat, bookedVat))} {" "}of {ZAR2(totals.incl)} billed
{/* VAT stays visible — the ex-VAT/incl-VAT split is the one figure a reader cannot reconstruct from the headline above. */}
{BOOK_INCLUSIVE_VAT ? `${ZAR2(bookedExVat)} ex VAT + ${ZAR2(bookedVat)} VAT` : `${ZAR2(bookedVat)} VAT not counted as income`}
{/* 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); // 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"); return ( setNames((m) => ({ ...m, [inv.id]: n }))} lineCount={lines.length} unbooked={unbooked.length} hasProblem={hasProblem} isOpen={isOpen} onToggle={() => setOpen(isOpen ? null : inv.id)} expanded={ isOpen ? (
) : null } detailW={detailW} /> ); })}
Invoice {partyScope === "external" ? "Supplier" : "Site"} Period Lines Revenue ex VAT VAT Total Status
); } function InvoiceRow({ invoice, name, partyScope, onNameSaved, lineCount, unbooked, hasProblem, isOpen, onToggle, expanded, detailW, }: { invoice: Invoice; name: string; partyScope: "internal" | "external"; onNameSaved: (name: string) => void; lineCount: number; unbooked: number; hasProblem: boolean; isOpen: boolean; onToggle: () => void; expanded: React.ReactNode; detailW: number | null; }) { return ( <> {/* Sticky first column so the invoice identity survives horizontal scroll. It repeats the row's own background so the flag never washes out. */} {partyScope === "external" ? ( // External: the name is a SUPPLIER and is not in the PDF, so it is typed in. ) : ( // Internal: the name is a SITE transcribed from the invoice's own description. )} {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 )} {isOpen && ( {/* Span the full table so the detail panel is not clipped by the sticky column. The panel is then pinned to the scroller's own width: sticky so it holds still while the list scrolls, and above the outer sticky cell so the two never overlap. Without the fixed width the inner table re-stretches the outer one and everything drifts right together. */}
{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. `hint` is supplied by the shared // register page because the two halves are filled differently — internal invoices are // seeded from data/source, external ones arrive through the upload dialog — so the same // "run npm run seed" advice would be wrong on one of them. export function EmptyInvoices({ hint }: { hint?: React.ReactNode }) { return (

No invoices yet.

{hint ?? ( <> Add a data/source/invoice-*.json then run{" "} npm run seed. )}

); } export { ZAR };