diff --git a/AGENTS.md b/AGENTS.md
index bb9f2fb..95c4c9b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -124,8 +124,24 @@ cells repeat it exactly.
Two more traps on this screen: a `flex-wrap` KPI row sizes tiles by CONTENT, so the wide
"booked to income" tile pushed the third one onto a second row below ~644px — it is a
-`grid-cols-3` now. And the two pickers in the **Booked to** cell are `shrink-0`; as flex
-children they compressed into each other by ~12px instead of sitting side by side.
+`grid-cols-3` now. And 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, and when they were not allowed to shrink they spilled past
+the edge of the table.
+
+## The machine dropdown — a portal is NOT inside its trigger
+
+`MachineCombobox` renders its menu 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.** The click-away
+handler tested `boxRef.contains(target)`, so every option in the menu read as an outside
+click: `mousedown` closed the menu, React unmounted the button, and the `click` that
+would have booked it never fired. Selecting a machine silently did nothing — the menu
+just closed. The menu now has its own ref and is treated as part of the control.
+
+Symptom to recognise: a dropdown opens, options highlight, and choosing one does nothing
+at all — no request, no error, no change. Suspect the outside-click test before the API.
## Money on invoices — VAT is NOT revenue
diff --git a/components/InvoiceTable.tsx b/components/InvoiceTable.tsx
index 5c2a573..09e53f5 100644
--- a/components/InvoiceTable.tsx
+++ b/components/InvoiceTable.tsx
@@ -24,11 +24,6 @@ export function InvoiceSummary({
// 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",
@@ -115,7 +110,6 @@ export function InvoiceSummary({
{unbooked.length} line{unbooked.length > 1 ? "s" : ""} not yet booked to a machine — choose a
plant item below.
-
) : (
@@ -123,7 +117,6 @@ export function InvoiceSummary({
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 (
-
-
-
-
- {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 (
@@ -246,7 +197,9 @@ export function InvoiceTable({ lines, assets }: { lines: BookedLine[]; assets: A
Hire ex VAT |
VAT |
Total incl |
- Booked to |
+
+ Booked to
+ |
@@ -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(
| |