From fd2afeba4e6902184b4554c60eee95c6864519d8 Mon Sep 17 00:00:00 2001
From: David
Date: Sat, 3 Oct 2026 15:43:46 +0200
Subject: [PATCH] Invoices: a machine chosen in the dropdown was never actually
booked
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
All {lines.length} lines booked to {periodMonth} income.
-
)}
@@ -164,48 +157,6 @@ export function InvoiceSummary({
);
}
-// 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 (
-
@@ -281,13 +234,15 @@ export function InvoiceTable({ lines, assets }: { lines: BookedLine[]; assets: A
{ZAR2(l.total_incl)}
-
- {/* shrink-0 on both: as flex children they were free to compress, and
- the site select ended up overlapping the machine picker by ~12px
- instead of sitting beside it. They may not overlap, so neither may
- shrink — the cell scrolls with the rest of the table if it must. */}
-
-
+
+ {/* 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. */}
+
+
(null);
const [highlight, setHighlight] = useState(0);
const boxRef = useRef(null);
+ const menuRef = useRef(null);
const inputRef = useRef(null);
const listRef = useRef(null);
// The line table lives inside an overflow-x-auto scroll container, which CLIPS any
@@ -112,10 +113,19 @@ export function MachineCombobox({
// Click away closes.
+ //
+ // The menu is PORTALED into , so it is not a DOM descendant of boxRef. Testing
+ // `boxRef.contains(target)` alone therefore 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. Selecting a machine silently did nothing. The
+ // menu has to be treated as part of the control.
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
- if (boxRef.current && !boxRef.current.contains(e.target as Node)) setOpen(false);
+ const t = e.target as Node;
+ if (boxRef.current?.contains(t)) return;
+ if (menuRef.current?.contains(t)) return;
+ setOpen(false);
};
document.addEventListener("mousedown", onDown);
return () => document.removeEventListener("mousedown", onDown);
@@ -231,6 +241,7 @@ export function MachineCombobox({
{open && anchor && createPortal(