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(