"use client"; import { useState } from "react"; import { ChevronRight, AlertTriangle, CheckCircle2, MapPin, FileText } 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"; 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, }: { invoices: Invoice[]; linesByInvoice: Record; assets: Asset[]; }) { 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); // 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 ( setOpen(isOpen ? null : inv.id)} expanded={ isOpen ? (
) : null } /> ); })}
Invoice Site Period Lines Revenue ex VAT VAT Total Status
); } function InvoiceRow({ invoice, lineCount, unbooked, hasProblem, isOpen, onToggle, expanded, }: { invoice: Invoice; lineCount: number; unbooked: number; 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 )} {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 };