import type { Asset, BookedLine, Invoice } from "@/lib/data"; import { ZAR2 } from "@/lib/utils"; import { AlertTriangle, CheckCircle2 } from "lucide-react"; import { MachineCombobox } from "@/components/MachineCombobox"; import { ScrollFade } from "@/components/ScrollFade"; import { SiteBookingSelect, SiteBookingLegend } from "@/components/SiteBookingSelect"; import { InvoiceUploader, AttachedDocument } from "@/components/InvoiceUploader"; export function InvoiceSummary({ invoice, lines, assets, embedded, }: { invoice: Invoice; lines: BookedLine[]; assets: Asset[]; // Inside the expandable invoice row the surrounding card already shows the number and // site, so the header block is dropped to avoid saying the same thing twice. embedded?: boolean; }) { const unbooked = lines.filter((l) => !l.isBooked); // Two different numbers, both worth showing. // ex-VAT = what actually lands in the P&L as income for the machine. // incl-VAT= what the debtor actually pays (ex-VAT + SARS VAT). // Booking is done ex-VAT: VAT is collected for SARS, never income. const period = `${invoice.period_from.slice(0, 10)} → ${invoice.period_to.slice(0, 10)}`; const periodMonth = new Date(invoice.period_to).toLocaleDateString("en-ZA", { month: "long", year: "numeric", timeZone: "UTC", }); return (
{!embedded && (
{invoice.invoice_no} {/* Site is transcribed from the invoice's own description. There is no sites collection yet — this is a plain label until one exists. */} {invoice.site && ( {invoice.site} )} {invoice.debtor}

{invoice.description}

Order {invoice.order_no || "—"} Debtor {invoice.debtor_no || "—"} {period} {invoice.currency || "ZAR"}
{/* The headline is the VAT-inclusive invoice total — what the debtor pays. */}
Invoice total (incl VAT)
{ZAR2(invoice.total_incl)}
{ZAR2(invoice.total_excl)} + {ZAR2(invoice.vat)} VAT {invoice.vat_pct ? ` @ ${invoice.vat_pct}%` : ""}
)} {embedded && (
{invoice.debtor} · {invoice.description}
Order {invoice.order_no || "—"} Debtor {invoice.debtor_no || "—"} {period}
Invoice total (incl VAT)
{ZAR2(invoice.total_incl)}
)} {/* Booking status. An unassigned line is never guessed at — it stays visible and the shortfall is stated, rather than being charged to a wrong machine. */} {unbooked.length > 0 ? (

{unbooked.length} line{unbooked.length > 1 ? "s" : ""} not yet booked to a machine — choose a plant item below.

) : (

All {lines.length} lines booked to {periodMonth} income.

)}
Source document
{invoice.source_pdf ? ( ) : ( )}
{invoice.escalation > 0 && }
); } function TotalRow({ label, value, strong }: { label: string; value: number; strong?: boolean }) { return (
{label}
{ZAR2(value || 0)}
); } export function InvoiceTable({ lines, assets }: { lines: BookedLine[]; assets: Asset[] }) { const machines = assets .filter((a) => a.is_active !== false) .map((a) => ({ id: a.id, code: a.code || "", name: a.name, category: a.category || "Other" })); return ( // Rule 1 + 4: the table scrolls inside its own container, so the page itself never // scrolls sideways at 375px. `-webkit-overflow-scrolling` is not a Tailwind utility — // written as a class it is inert — so it is set inline to actually take effect.
{lines.map((l) => ( {/* Sticky first column — keeps the machine identifiable while scrolling. It needs its own opaque background, so it has to repeat the row's unbooked tint; otherwise the flag disappears under the white. */} ))}
Machine Description Qty Unit Rate Hire ex VAT VAT Total incl Booked to
{l.line_no} {l.machine_code}
{l.description} {l.qty} {l.unit} {ZAR2(l.rate)} {ZAR2(l.amount_excl)} {ZAR2(l.vat)} {ZAR2(l.total_incl)} {/* Stacked, not side by side. At 768px the Booked to column is only ~166px, so two 200px pickers could never fit across — as flex children they were compressed into each other by ~12px, and once forced not to shrink they spilled PAST the edge of the table. The column now declares a min-width, the controls stack inside it, and the table scrolls inside its own box instead of overflowing. */}
{(!l.isBooked || l.booked_site) && ( )}
{/* Both pickers save the moment you choose — there is no Save button to miss, which is exactly why an unmatched row read as "nothing happens". Say so on the row rather than leaving it to be discovered. */} {!l.isBooked && (

Pick a machine above to save it. No match in the register? Book it to the site instead — it posts no income, because no machine carries it.

)}
); }