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 sum = (ls: BookedLine[], k: "amount_excl" | "vat") => ls.reduce((s, l) => s + (l[k] || 0), 0);
const bookedExVat = sum(lines.filter((l) => l.isBooked), "amount_excl");
const bookedVat = sum(lines.filter((l) => l.isBooked), "vat");
const unbookedExVat = sum(unbooked, "amount_excl");
const unbookedVat = sum(unbooked, "vat");
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 && }
);
}
// Shows the booked money three ways at once, so nobody has to guess which figure the
// dashboard is using:
//
// booked to income (ex VAT) → what lands in the P&L for the machine
// VAT → collected for SARS, NOT income
// billed to customer → ex-VAT + VAT, what the debtor actually pays
function BookingMoney({
bookedExVat,
bookedVat,
unbookedExVat = 0,
unbookedVat = 0,
}: {
bookedExVat: number;
bookedVat: number;
unbookedExVat?: number;
unbookedVat?: number;
}) {
return (
{unbookedExVat > 0 && (
<>
>
)}
);
}
function Item({ label, value, strong }: { label: string; value: number; strong?: boolean }) {
return (
{label}
{ZAR2(value)}
);
}
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.
|
Machine
|
Description |
Qty |
Unit |
Rate |
Hire ex VAT |
VAT |
Total incl |
Booked to |
{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. */}
|
{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)}
|
{(!l.isBooked || l.booked_site) && (
)}
|
))}
);
}