Selecting a plant item in the Booked to dropdown silently did nothing. The menu opened, the option highlighted, and clicking it produced no request at all — the menu just closed. Reproduced with a real browser click: no POST to /book. The menu is rendered with createPortal(..., document.body) because the line table lives in an overflow-x-auto container that would clip an in-place dropdown. A portal is not a DOM descendant of the component that made it, so the click-away handler's `boxRef.contains(target)` reported every option as an outside click: mousedown closed the menu, React unmounted the button, and the click that would have booked it never landed. The menu now has its own ref and is treated as part of the control. Verified with a real click — the booking posts, the site clears, and the unbooked badge goes. The site dropdown was never affected; it is a native <select>, outside the portal. Also on this screen: - The Booked to column needs min-w-[240px] with the two pickers stacked inside it. At 768px the column is only ~166px, so two 200px controls could not sit side by side; as flex children they compressed into each other by ~12px, and once they were not allowed to shrink they spilled past the edge of the table. - Dropped the "Booked to income / VAT to SARS / Billed to customer / Still unbooked" money block, its component and the variables feeding it. The status line stays: "1 line not yet booked to a machine". Typecheck and build green. Checked at 768x1024 and 375x812: no page overflow, no control past the table edge, no tap target under 44px.
276 lines
No EOL
14 KiB
TypeScript
276 lines
No EOL
14 KiB
TypeScript
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 (
|
|
<div
|
|
className={
|
|
embedded
|
|
? "bg-white dark:bg-surface border border-gray-100 dark:border-border rounded-xl shadow-sm overflow-hidden"
|
|
: "bg-white dark:bg-surface border border-gray-100 dark:border-border rounded-2xl shadow-sm overflow-hidden mb-6"
|
|
}
|
|
>
|
|
{!embedded && (
|
|
<div className="p-5 border-b border-gray-100 dark:border-border">
|
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
|
<div className="min-w-0">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<span className="font-mono text-sm font-semibold px-2.5 py-1 rounded-md bg-[#00798C]/10 text-[#00798C]">
|
|
{invoice.invoice_no}
|
|
</span>
|
|
{/* 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 && (
|
|
<span className="text-sm px-2.5 py-1 rounded-md bg-[#EDAE49]/15 text-[#8a6420] dark:text-[#EDAE49]">
|
|
{invoice.site}
|
|
</span>
|
|
)}
|
|
<span className="text-sm font-medium text-[#003D5B] dark:text-white">{invoice.debtor}</span>
|
|
</div>
|
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">{invoice.description}</p>
|
|
<div className="flex flex-wrap gap-x-4 gap-y-1 mt-2 text-sm text-gray-500 dark:text-gray-400">
|
|
<span>Order {invoice.order_no || "—"}</span>
|
|
<span>Debtor {invoice.debtor_no || "—"}</span>
|
|
<span>{period}</span>
|
|
<span>{invoice.currency || "ZAR"}</span>
|
|
</div>
|
|
</div>
|
|
{/* The headline is the VAT-inclusive invoice total — what the debtor pays. */}
|
|
<div className="text-right shrink-0">
|
|
<div className="text-sm text-gray-500 dark:text-gray-400">Invoice total (incl VAT)</div>
|
|
<div className="text-2xl font-semibold tabular-nums text-[#003D5B] dark:text-white">
|
|
{ZAR2(invoice.total_incl)}
|
|
</div>
|
|
<div className="text-sm text-gray-500 dark:text-gray-400 tabular-nums">
|
|
{ZAR2(invoice.total_excl)} + {ZAR2(invoice.vat)} VAT
|
|
{invoice.vat_pct ? ` @ ${invoice.vat_pct}%` : ""}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{embedded && (
|
|
<div className="px-4 py-3 border-b border-gray-100 dark:border-border flex flex-wrap items-center justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<div className="text-sm font-medium text-[#003D5B] dark:text-white">
|
|
{invoice.debtor} · {invoice.description}
|
|
</div>
|
|
<div className="flex flex-wrap gap-x-4 gap-y-1 mt-1 text-sm text-gray-500 dark:text-gray-400">
|
|
<span>Order {invoice.order_no || "—"}</span>
|
|
<span>Debtor {invoice.debtor_no || "—"}</span>
|
|
<span>{period}</span>
|
|
</div>
|
|
</div>
|
|
<div className="text-right shrink-0">
|
|
<div className="text-sm text-gray-500 dark:text-gray-400">Invoice total (incl VAT)</div>
|
|
<div className="text-xl font-semibold tabular-nums text-[#003D5B] dark:text-white">
|
|
{ZAR2(invoice.total_incl)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 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 ? (
|
|
<div className="flex items-start gap-3 px-5 py-3 bg-[#D1495B]/5 border-b border-[#D1495B]/15">
|
|
<AlertTriangle className="w-4 h-4 text-[#D1495B] shrink-0 mt-0.5" />
|
|
<div className="text-sm text-[#D1495B] dark:text-brick-ink">
|
|
<p>
|
|
{unbooked.length} line{unbooked.length > 1 ? "s" : ""} not yet booked to a machine — choose a
|
|
plant item below.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="flex items-start gap-3 px-5 py-3 bg-[#00798C]/5 border-b border-[#00798C]/15">
|
|
<CheckCircle2 className="w-4 h-4 text-[#00798C] shrink-0 mt-0.5" />
|
|
<div className="text-sm text-[#00798C]">
|
|
<p>All {lines.length} lines booked to {periodMonth} income.</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="px-5 py-3 border-b border-gray-100 dark:border-white/5">
|
|
<SiteBookingLegend />
|
|
</div>
|
|
|
|
<InvoiceTable lines={lines} assets={assets} />
|
|
|
|
<div className="p-5 border-t border-gray-100 dark:border-border bg-gray-50/50 dark:bg-white/[0.04]">
|
|
<div className="flex flex-col lg:flex-row lg:items-start justify-between gap-6">
|
|
<div className="lg:max-w-sm w-full">
|
|
<div className="text-sm font-medium text-[#003D5B] dark:text-white mb-2">
|
|
Source document
|
|
</div>
|
|
{invoice.source_pdf ? (
|
|
<AttachedDocument invoiceNo={invoice.invoice_no} invoiceId={invoice.id} />
|
|
) : (
|
|
<InvoiceUploader invoiceNo={invoice.invoice_no} />
|
|
)}
|
|
</div>
|
|
|
|
<dl className="w-full lg:w-80 shrink-0 space-y-2">
|
|
<TotalRow label="Hire (excl VAT)" value={invoice.total_excl} />
|
|
{invoice.escalation > 0 && <TotalRow label="Escalation" value={invoice.escalation} />}
|
|
<TotalRow
|
|
label={`VAT${invoice.vat_pct ? ` @ ${invoice.vat_pct}%` : ""}`}
|
|
value={invoice.vat}
|
|
/>
|
|
<div className="border-t border-gray-200 dark:border-white/10 pt-2">
|
|
<TotalRow label="Invoice total (incl VAT)" value={invoice.total_incl} strong />
|
|
</div>
|
|
</dl>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
|
|
function TotalRow({ label, value, strong }: { label: string; value: number; strong?: boolean }) {
|
|
return (
|
|
<div className="flex items-center justify-between gap-4">
|
|
<dt className="text-sm text-gray-500 dark:text-gray-400">{label}</dt>
|
|
<dd
|
|
className={`text-sm tabular-nums ${
|
|
strong ? "font-semibold text-[#003D5B] dark:text-white" : "text-gray-700 dark:text-gray-300"
|
|
}`}
|
|
>
|
|
{ZAR2(value || 0)}
|
|
</dd>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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.
|
|
<div className="group relative">
|
|
<div className="overflow-x-auto" style={{ WebkitOverflowScrolling: "touch" }}>
|
|
<table className="w-full text-sm min-w-[900px]">
|
|
<thead>
|
|
<tr className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider border-b border-gray-100 dark:border-border bg-gray-50/50 dark:bg-white/[0.04]">
|
|
<th className="text-left py-3 px-5 font-medium sticky left-0 z-10 bg-gray-50/95 dark:bg-surface-muted/95 backdrop-blur">
|
|
Machine
|
|
</th>
|
|
<th className="text-left py-3 px-3 font-medium">Description</th>
|
|
<th className="text-right py-3 px-3 font-medium whitespace-nowrap">Qty</th>
|
|
<th className="text-left py-3 px-3 font-medium">Unit</th>
|
|
<th className="text-right py-3 px-3 font-medium whitespace-nowrap">Rate</th>
|
|
<th className="text-right py-3 px-3 font-medium whitespace-nowrap">Hire 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</th>
|
|
<th className="text-left py-3 px-3 font-medium whitespace-nowrap min-w-[240px]">
|
|
Booked to
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{lines.map((l) => (
|
|
<tr
|
|
key={l.id}
|
|
className={`border-b border-gray-50 dark:border-border ${
|
|
// SOLID, not an alpha tint — see the note in InvoiceRow. A translucent
|
|
// sticky cell lets the columns it covers show through it on scroll.
|
|
l.isBooked ? "hover:bg-gray-50/40 dark:hover:bg-white/[0.02]" : "bg-[#FDF3F4] dark:bg-[#292934]"
|
|
}`}
|
|
>
|
|
{/* 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. */}
|
|
<td
|
|
className={`py-3 px-5 sticky left-0 z-10 ${
|
|
l.isBooked ? "bg-white dark:bg-surface" : "bg-[#FDF3F4] dark:bg-[#292934]"
|
|
}`}
|
|
>
|
|
<div className="flex items-center gap-2 whitespace-nowrap">
|
|
<span className="text-xs text-gray-400 dark:text-text-soft tabular-nums">{l.line_no}</span>
|
|
<span className="font-mono text-xs font-semibold text-[#00798C]">{l.machine_code}</span>
|
|
</div>
|
|
</td>
|
|
<td className="py-3 px-3 text-gray-700 dark:text-gray-300 whitespace-nowrap">{l.description}</td>
|
|
<td className="py-3 px-3 text-right tabular-nums text-gray-700 dark:text-gray-300">{l.qty}</td>
|
|
<td className="py-3 px-3 text-gray-500 dark:text-gray-400">{l.unit}</td>
|
|
<td className="py-3 px-3 text-right tabular-nums text-gray-700 dark:text-gray-300">{ZAR2(l.rate)}</td>
|
|
<td className="py-3 px-3 text-right tabular-nums text-gray-700 dark:text-gray-300">{ZAR2(l.amount_excl)}</td>
|
|
<td className="py-3 px-3 text-right tabular-nums text-gray-700 dark:text-gray-300">{ZAR2(l.vat)}</td>
|
|
<td className="py-3 px-3 text-right tabular-nums font-semibold text-[#003D5B] dark:text-white whitespace-nowrap">
|
|
{ZAR2(l.total_incl)}
|
|
</td>
|
|
<td className="py-3 px-3 min-w-[240px]">
|
|
{/* 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. */}
|
|
<div className="flex flex-col gap-2">
|
|
<div className="min-w-[220px]">
|
|
<MachineCombobox
|
|
lineId={l.id}
|
|
value={l.booked_asset || ""}
|
|
machines={machines}
|
|
lineCode={l.machine_code}
|
|
lineDescription={l.description}
|
|
/>
|
|
</div>
|
|
{(!l.isBooked || l.booked_site) && (
|
|
<SiteBookingSelect lineId={l.id} value={l.booked_site || ""} />
|
|
)}
|
|
</div>
|
|
{/* 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 && (
|
|
<p className="mt-1.5 text-xs text-[#D1495B]">
|
|
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.
|
|
</p>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<ScrollFade />
|
|
</div>
|
|
);
|
|
} |