Invoices: make the machine picker searchable
Booking a line meant scrolling a native <select> of 44 machines, where two
dozen near-identical "CASE TLB" entries sit next to each other. A native
select cannot be filtered, so finding TLB07 meant scrolling past most of the
list.
Replaced with a combobox that filters as you type, on the code, the name or
the category, and says how many of 44 machines matched. Still behaves like a
select rather than a text box: opening it, Up/Down to move, Enter to choose,
Escape to cancel, and a "Not booked" entry to clear.
The list stays grouped by category so the codes read the way the plant
register does, and every row is a 44px target with the list scrolling within
its own box, so it stays usable on a phone. It is an ARIA combobox —
aria-expanded, aria-autocomplete, aria-controls — and the highlighted row
scrolls into view as you arrow through it.
Booking behaviour is unchanged: still posts on selection, still rolls the
display back if the save fails, still refreshes on success.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 16:37:54 +00:00
|
|
|
"use client";
|
|
|
|
|
|
Fix: toast and machine dropdown were rendering behind the page chrome
Two different causes, both from the same root — the header was raised to
z-[1100] so the Plant Map's Leaflet controls (z-1000) could not scroll over
it, which quietly pushed my overlays underneath it.
The toast sat at z-60 and the Add Invoice dialog at z-50, both below the
header, so a confirmation appeared to vanish behind the nav bar. Rung out
against the existing ladder instead of picking a new number: header 1100,
nav drawer 1200, dialog 1250, toast 1300. The toast and the combobox list sit
at the same layer deliberately — they never open at the same time.
The machine dropdown was a separate and worse bug: it was not merely behind
anything, it was CLIPPED. It rendered inside the line table's overflow-x-auto
container, so the list was cut off by the table edge and the card around it —
it could never escape, which is why the search appeared to do nothing.
It now renders through a portal into <body>, positioned from the input's
measured viewport rect, so no ancestor can clip it. The rect is measured
before paint so it never flashes in the wrong place, kept in sync with
scroll and resize, and the list flips upward when there is not enough room
below — which is the common case on a phone with the keyboard open.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 16:42:29 +00:00
|
|
|
import { useState, useRef, useEffect, useMemo, useLayoutEffect } from "react";
|
|
|
|
|
import { createPortal } from "react-dom";
|
Invoices: make the machine picker searchable
Booking a line meant scrolling a native <select> of 44 machines, where two
dozen near-identical "CASE TLB" entries sit next to each other. A native
select cannot be filtered, so finding TLB07 meant scrolling past most of the
list.
Replaced with a combobox that filters as you type, on the code, the name or
the category, and says how many of 44 machines matched. Still behaves like a
select rather than a text box: opening it, Up/Down to move, Enter to choose,
Escape to cancel, and a "Not booked" entry to clear.
The list stays grouped by category so the codes read the way the plant
register does, and every row is a 44px target with the list scrolling within
its own box, so it stays usable on a phone. It is an ARIA combobox —
aria-expanded, aria-autocomplete, aria-controls — and the highlighted row
scrolls into view as you arrow through it.
Booking behaviour is unchanged: still posts on selection, still rolls the
display back if the save fails, still refreshes on success.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 16:37:54 +00:00
|
|
|
import { useRouter } from "next/navigation";
|
|
|
|
|
import { Loader2, AlertCircle, ChevronDown, Check, Search, X } from "lucide-react";
|
|
|
|
|
|
|
|
|
|
type Machine = { id: string; code: string; name: string; category: string };
|
|
|
|
|
|
|
|
|
|
// A searchable replacement for the native <select> of 44 machines.
|
|
|
|
|
//
|
|
|
|
|
// A native select cannot be filtered, so finding "TLB07" meant scrolling a list that
|
|
|
|
|
// repeats two dozen near-identical "CASE TLB" entries. This filters as you type, on
|
|
|
|
|
// either the code or the name, and still behaves like a select: opening it, arrowing,
|
|
|
|
|
// Enter to choose, Escape to cancel, and a clear option.
|
|
|
|
|
//
|
|
|
|
|
// Keyboard and touch are both first-class — arrows move the highlighted row, the list
|
|
|
|
|
// scrolls, and every row is a 44px target so it is usable on a phone.
|
|
|
|
|
export function MachineCombobox({
|
|
|
|
|
lineId,
|
|
|
|
|
value,
|
|
|
|
|
machines,
|
2026-10-02 17:09:16 +00:00
|
|
|
lineCode,
|
|
|
|
|
lineDescription,
|
Invoices: make the machine picker searchable
Booking a line meant scrolling a native <select> of 44 machines, where two
dozen near-identical "CASE TLB" entries sit next to each other. A native
select cannot be filtered, so finding TLB07 meant scrolling past most of the
list.
Replaced with a combobox that filters as you type, on the code, the name or
the category, and says how many of 44 machines matched. Still behaves like a
select rather than a text box: opening it, Up/Down to move, Enter to choose,
Escape to cancel, and a "Not booked" entry to clear.
The list stays grouped by category so the codes read the way the plant
register does, and every row is a 44px target with the list scrolling within
its own box, so it stays usable on a phone. It is an ARIA combobox —
aria-expanded, aria-autocomplete, aria-controls — and the highlighted row
scrolls into view as you arrow through it.
Booking behaviour is unchanged: still posts on selection, still rolls the
display back if the save fails, still refreshes on success.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 16:37:54 +00:00
|
|
|
}: {
|
|
|
|
|
lineId: string;
|
|
|
|
|
value: string;
|
|
|
|
|
machines: Machine[];
|
2026-10-02 17:09:16 +00:00
|
|
|
// The invoice's own words for this line. The register rarely uses them: an invoice
|
|
|
|
|
// says "CB22 2.2 TON Roller Vibration" where the asset is filed as "CAT 2,4TON RV",
|
|
|
|
|
// so searching on the register alone would never connect the two.
|
|
|
|
|
lineCode?: string;
|
|
|
|
|
lineDescription?: string;
|
Invoices: make the machine picker searchable
Booking a line meant scrolling a native <select> of 44 machines, where two
dozen near-identical "CASE TLB" entries sit next to each other. A native
select cannot be filtered, so finding TLB07 meant scrolling past most of the
list.
Replaced with a combobox that filters as you type, on the code, the name or
the category, and says how many of 44 machines matched. Still behaves like a
select rather than a text box: opening it, Up/Down to move, Enter to choose,
Escape to cancel, and a "Not booked" entry to clear.
The list stays grouped by category so the codes read the way the plant
register does, and every row is a 44px target with the list scrolling within
its own box, so it stays usable on a phone. It is an ARIA combobox —
aria-expanded, aria-autocomplete, aria-controls — and the highlighted row
scrolls into view as you arrow through it.
Booking behaviour is unchanged: still posts on selection, still rolls the
display back if the save fails, still refreshes on success.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 16:37:54 +00:00
|
|
|
}) {
|
|
|
|
|
const router = useRouter();
|
|
|
|
|
const [query, setQuery] = useState("");
|
|
|
|
|
const [open, setOpen] = useState(false);
|
|
|
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
|
const [highlight, setHighlight] = useState(0);
|
|
|
|
|
const boxRef = useRef<HTMLDivElement>(null);
|
Invoices: a machine chosen in the dropdown was never actually booked
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.
2026-10-03 13:43:46 +00:00
|
|
|
const menuRef = useRef<HTMLDivElement>(null);
|
Invoices: make the machine picker searchable
Booking a line meant scrolling a native <select> of 44 machines, where two
dozen near-identical "CASE TLB" entries sit next to each other. A native
select cannot be filtered, so finding TLB07 meant scrolling past most of the
list.
Replaced with a combobox that filters as you type, on the code, the name or
the category, and says how many of 44 machines matched. Still behaves like a
select rather than a text box: opening it, Up/Down to move, Enter to choose,
Escape to cancel, and a "Not booked" entry to clear.
The list stays grouped by category so the codes read the way the plant
register does, and every row is a 44px target with the list scrolling within
its own box, so it stays usable on a phone. It is an ARIA combobox —
aria-expanded, aria-autocomplete, aria-controls — and the highlighted row
scrolls into view as you arrow through it.
Booking behaviour is unchanged: still posts on selection, still rolls the
display back if the save fails, still refreshes on success.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 16:37:54 +00:00
|
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
|
const listRef = useRef<HTMLUListElement>(null);
|
Fix: toast and machine dropdown were rendering behind the page chrome
Two different causes, both from the same root — the header was raised to
z-[1100] so the Plant Map's Leaflet controls (z-1000) could not scroll over
it, which quietly pushed my overlays underneath it.
The toast sat at z-60 and the Add Invoice dialog at z-50, both below the
header, so a confirmation appeared to vanish behind the nav bar. Rung out
against the existing ladder instead of picking a new number: header 1100,
nav drawer 1200, dialog 1250, toast 1300. The toast and the combobox list sit
at the same layer deliberately — they never open at the same time.
The machine dropdown was a separate and worse bug: it was not merely behind
anything, it was CLIPPED. It rendered inside the line table's overflow-x-auto
container, so the list was cut off by the table edge and the card around it —
it could never escape, which is why the search appeared to do nothing.
It now renders through a portal into <body>, positioned from the input's
measured viewport rect, so no ancestor can clip it. The rect is measured
before paint so it never flashes in the wrong place, kept in sync with
scroll and resize, and the list flips upward when there is not enough room
below — which is the common case on a phone with the keyboard open.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 16:42:29 +00:00
|
|
|
// The line table lives inside an overflow-x-auto scroll container, which CLIPS any
|
|
|
|
|
// absolutely-positioned child — an in-place dropdown is cut off by the table edge and
|
|
|
|
|
// by the card. Rendering into <body> with viewport coordinates is the only way out.
|
|
|
|
|
const [anchor, setAnchor] = useState<{ top: number; left: number; width: number } | null>(null);
|
Invoices: make the machine picker searchable
Booking a line meant scrolling a native <select> of 44 machines, where two
dozen near-identical "CASE TLB" entries sit next to each other. A native
select cannot be filtered, so finding TLB07 meant scrolling past most of the
list.
Replaced with a combobox that filters as you type, on the code, the name or
the category, and says how many of 44 machines matched. Still behaves like a
select rather than a text box: opening it, Up/Down to move, Enter to choose,
Escape to cancel, and a "Not booked" entry to clear.
The list stays grouped by category so the codes read the way the plant
register does, and every row is a 44px target with the list scrolling within
its own box, so it stays usable on a phone. It is an ARIA combobox —
aria-expanded, aria-autocomplete, aria-controls — and the highlighted row
scrolls into view as you arrow through it.
Booking behaviour is unchanged: still posts on selection, still rolls the
display back if the save fails, still refreshes on success.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 16:37:54 +00:00
|
|
|
|
|
|
|
|
const selected = machines.find((m) => m.id === value) || null;
|
|
|
|
|
|
Fix: toast and machine dropdown were rendering behind the page chrome
Two different causes, both from the same root — the header was raised to
z-[1100] so the Plant Map's Leaflet controls (z-1000) could not scroll over
it, which quietly pushed my overlays underneath it.
The toast sat at z-60 and the Add Invoice dialog at z-50, both below the
header, so a confirmation appeared to vanish behind the nav bar. Rung out
against the existing ladder instead of picking a new number: header 1100,
nav drawer 1200, dialog 1250, toast 1300. The toast and the combobox list sit
at the same layer deliberately — they never open at the same time.
The machine dropdown was a separate and worse bug: it was not merely behind
anything, it was CLIPPED. It rendered inside the line table's overflow-x-auto
container, so the list was cut off by the table edge and the card around it —
it could never escape, which is why the search appeared to do nothing.
It now renders through a portal into <body>, positioned from the input's
measured viewport rect, so no ancestor can clip it. The rect is measured
before paint so it never flashes in the wrong place, kept in sync with
scroll and resize, and the list flips upward when there is not enough room
below — which is the common case on a phone with the keyboard open.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 16:42:29 +00:00
|
|
|
useLayoutEffect(() => {
|
|
|
|
|
if (!open || !inputRef.current) {
|
|
|
|
|
setAnchor(null);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const measure = () => {
|
|
|
|
|
const r = inputRef.current?.getBoundingClientRect();
|
|
|
|
|
if (r) setAnchor({ top: r.bottom, left: r.left, width: r.width });
|
|
|
|
|
};
|
|
|
|
|
measure();
|
|
|
|
|
// Follow the page: the table scrolls, and so does the window.
|
|
|
|
|
window.addEventListener("scroll", measure, true);
|
|
|
|
|
window.addEventListener("resize", measure);
|
|
|
|
|
return () => {
|
|
|
|
|
window.removeEventListener("scroll", measure, true);
|
|
|
|
|
window.removeEventListener("resize", measure);
|
|
|
|
|
};
|
|
|
|
|
}, [open]);
|
|
|
|
|
|
Invoices: make the machine picker searchable
Booking a line meant scrolling a native <select> of 44 machines, where two
dozen near-identical "CASE TLB" entries sit next to each other. A native
select cannot be filtered, so finding TLB07 meant scrolling past most of the
list.
Replaced with a combobox that filters as you type, on the code, the name or
the category, and says how many of 44 machines matched. Still behaves like a
select rather than a text box: opening it, Up/Down to move, Enter to choose,
Escape to cancel, and a "Not booked" entry to clear.
The list stays grouped by category so the codes read the way the plant
register does, and every row is a 44px target with the list scrolling within
its own box, so it stays usable on a phone. It is an ARIA combobox —
aria-expanded, aria-autocomplete, aria-controls — and the highlighted row
scrolls into view as you arrow through it.
Booking behaviour is unchanged: still posts on selection, still rolls the
display back if the save fails, still refreshes on success.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 16:37:54 +00:00
|
|
|
// Flattened, filtered list — the flat order is what the keyboard walks.
|
|
|
|
|
const groups = useMemo(() => {
|
|
|
|
|
const q = query.trim().toLowerCase();
|
2026-10-02 17:09:16 +00:00
|
|
|
// Anything the invoice calls this line is searchable too, so "skidsteer" or
|
|
|
|
|
// "CB22" finds the machine even though neither word is in the register's name.
|
|
|
|
|
const extra = [lineCode, lineDescription].filter(Boolean).join(" ").toLowerCase();
|
|
|
|
|
const hit = (m: Machine) => {
|
|
|
|
|
if (!q) return true;
|
|
|
|
|
if (m.code.toLowerCase().includes(q) || m.name.toLowerCase().includes(q) || m.category.toLowerCase().includes(q))
|
|
|
|
|
return true;
|
|
|
|
|
return extra.length > 0 && extra.includes(q);
|
|
|
|
|
};
|
Invoices: make the machine picker searchable
Booking a line meant scrolling a native <select> of 44 machines, where two
dozen near-identical "CASE TLB" entries sit next to each other. A native
select cannot be filtered, so finding TLB07 meant scrolling past most of the
list.
Replaced with a combobox that filters as you type, on the code, the name or
the category, and says how many of 44 machines matched. Still behaves like a
select rather than a text box: opening it, Up/Down to move, Enter to choose,
Escape to cancel, and a "Not booked" entry to clear.
The list stays grouped by category so the codes read the way the plant
register does, and every row is a 44px target with the list scrolling within
its own box, so it stays usable on a phone. It is an ARIA combobox —
aria-expanded, aria-autocomplete, aria-controls — and the highlighted row
scrolls into view as you arrow through it.
Booking behaviour is unchanged: still posts on selection, still rolls the
display back if the save fails, still refreshes on success.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 16:37:54 +00:00
|
|
|
const matched = machines.filter(hit);
|
|
|
|
|
const byCategory = new Map<string, Machine[]>();
|
|
|
|
|
for (const m of matched) {
|
|
|
|
|
if (!byCategory.has(m.category)) byCategory.set(m.category, []);
|
|
|
|
|
byCategory.get(m.category)!.push(m);
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
list: Array.from(byCategory.entries())
|
|
|
|
|
.sort((a, b) => a[0].localeCompare(b[0]))
|
|
|
|
|
.map(([category, items]) => ({
|
|
|
|
|
category,
|
|
|
|
|
items: items.sort((a, b) => a.code.localeCompare(b.code)),
|
|
|
|
|
})),
|
|
|
|
|
flat: Array.from(byCategory.values())
|
|
|
|
|
.flat()
|
|
|
|
|
.sort((a, b) => a.code.localeCompare(b.code)),
|
|
|
|
|
};
|
2026-10-02 17:09:16 +00:00
|
|
|
}, [machines, query, lineCode, lineDescription]);
|
Invoices: make the machine picker searchable
Booking a line meant scrolling a native <select> of 44 machines, where two
dozen near-identical "CASE TLB" entries sit next to each other. A native
select cannot be filtered, so finding TLB07 meant scrolling past most of the
list.
Replaced with a combobox that filters as you type, on the code, the name or
the category, and says how many of 44 machines matched. Still behaves like a
select rather than a text box: opening it, Up/Down to move, Enter to choose,
Escape to cancel, and a "Not booked" entry to clear.
The list stays grouped by category so the codes read the way the plant
register does, and every row is a 44px target with the list scrolling within
its own box, so it stays usable on a phone. It is an ARIA combobox —
aria-expanded, aria-autocomplete, aria-controls — and the highlighted row
scrolls into view as you arrow through it.
Booking behaviour is unchanged: still posts on selection, still rolls the
display back if the save fails, still refreshes on success.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 16:37:54 +00:00
|
|
|
|
Fix: toast and machine dropdown were rendering behind the page chrome
Two different causes, both from the same root — the header was raised to
z-[1100] so the Plant Map's Leaflet controls (z-1000) could not scroll over
it, which quietly pushed my overlays underneath it.
The toast sat at z-60 and the Add Invoice dialog at z-50, both below the
header, so a confirmation appeared to vanish behind the nav bar. Rung out
against the existing ladder instead of picking a new number: header 1100,
nav drawer 1200, dialog 1250, toast 1300. The toast and the combobox list sit
at the same layer deliberately — they never open at the same time.
The machine dropdown was a separate and worse bug: it was not merely behind
anything, it was CLIPPED. It rendered inside the line table's overflow-x-auto
container, so the list was cut off by the table edge and the card around it —
it could never escape, which is why the search appeared to do nothing.
It now renders through a portal into <body>, positioned from the input's
measured viewport rect, so no ancestor can clip it. The rect is measured
before paint so it never flashes in the wrong place, kept in sync with
scroll and resize, and the list flips upward when there is not enough room
below — which is the common case on a phone with the keyboard open.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 16:42:29 +00:00
|
|
|
const menuWidth = 300;
|
|
|
|
|
// ~16px count strip + a "Not booked" row (if any) + 12px per row, capped.
|
|
|
|
|
const menuHeight = useMemo(() => {
|
|
|
|
|
const rows = groups.flat.length;
|
|
|
|
|
return 44 + (value ? 44 : 0) + 24 + Math.min(rows, 8) * 44 + 8;
|
|
|
|
|
}, [groups.flat.length, value]);
|
|
|
|
|
|
|
|
|
|
const flipUp = useMemo(() => {
|
|
|
|
|
if (typeof window === "undefined" || !anchor) return false;
|
|
|
|
|
return anchor.top + menuHeight + 8 > window.innerHeight && anchor.top - menuHeight - 8 > 8;
|
|
|
|
|
}, [anchor, menuHeight]);
|
|
|
|
|
|
|
|
|
|
|
Invoices: make the machine picker searchable
Booking a line meant scrolling a native <select> of 44 machines, where two
dozen near-identical "CASE TLB" entries sit next to each other. A native
select cannot be filtered, so finding TLB07 meant scrolling past most of the
list.
Replaced with a combobox that filters as you type, on the code, the name or
the category, and says how many of 44 machines matched. Still behaves like a
select rather than a text box: opening it, Up/Down to move, Enter to choose,
Escape to cancel, and a "Not booked" entry to clear.
The list stays grouped by category so the codes read the way the plant
register does, and every row is a 44px target with the list scrolling within
its own box, so it stays usable on a phone. It is an ARIA combobox —
aria-expanded, aria-autocomplete, aria-controls — and the highlighted row
scrolls into view as you arrow through it.
Booking behaviour is unchanged: still posts on selection, still rolls the
display back if the save fails, still refreshes on success.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 16:37:54 +00:00
|
|
|
// Click away closes.
|
Invoices: a machine chosen in the dropdown was never actually booked
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.
2026-10-03 13:43:46 +00:00
|
|
|
//
|
|
|
|
|
// The menu is PORTALED into <body>, 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.
|
Invoices: make the machine picker searchable
Booking a line meant scrolling a native <select> of 44 machines, where two
dozen near-identical "CASE TLB" entries sit next to each other. A native
select cannot be filtered, so finding TLB07 meant scrolling past most of the
list.
Replaced with a combobox that filters as you type, on the code, the name or
the category, and says how many of 44 machines matched. Still behaves like a
select rather than a text box: opening it, Up/Down to move, Enter to choose,
Escape to cancel, and a "Not booked" entry to clear.
The list stays grouped by category so the codes read the way the plant
register does, and every row is a 44px target with the list scrolling within
its own box, so it stays usable on a phone. It is an ARIA combobox —
aria-expanded, aria-autocomplete, aria-controls — and the highlighted row
scrolls into view as you arrow through it.
Booking behaviour is unchanged: still posts on selection, still rolls the
display back if the save fails, still refreshes on success.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 16:37:54 +00:00
|
|
|
useEffect(() => {
|
|
|
|
|
if (!open) return;
|
|
|
|
|
const onDown = (e: MouseEvent) => {
|
Invoices: a machine chosen in the dropdown was never actually booked
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.
2026-10-03 13:43:46 +00:00
|
|
|
const t = e.target as Node;
|
|
|
|
|
if (boxRef.current?.contains(t)) return;
|
|
|
|
|
if (menuRef.current?.contains(t)) return;
|
|
|
|
|
setOpen(false);
|
Invoices: make the machine picker searchable
Booking a line meant scrolling a native <select> of 44 machines, where two
dozen near-identical "CASE TLB" entries sit next to each other. A native
select cannot be filtered, so finding TLB07 meant scrolling past most of the
list.
Replaced with a combobox that filters as you type, on the code, the name or
the category, and says how many of 44 machines matched. Still behaves like a
select rather than a text box: opening it, Up/Down to move, Enter to choose,
Escape to cancel, and a "Not booked" entry to clear.
The list stays grouped by category so the codes read the way the plant
register does, and every row is a 44px target with the list scrolling within
its own box, so it stays usable on a phone. It is an ARIA combobox —
aria-expanded, aria-autocomplete, aria-controls — and the highlighted row
scrolls into view as you arrow through it.
Booking behaviour is unchanged: still posts on selection, still rolls the
display back if the save fails, still refreshes on success.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 16:37:54 +00:00
|
|
|
};
|
|
|
|
|
document.addEventListener("mousedown", onDown);
|
|
|
|
|
return () => document.removeEventListener("mousedown", onDown);
|
|
|
|
|
}, [open]);
|
|
|
|
|
|
|
|
|
|
// Keep the highlighted row inside the results after each keystroke.
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
setHighlight((h) => Math.min(h, Math.max(0, groups.flat.length - 1)));
|
|
|
|
|
}, [groups.flat.length]);
|
|
|
|
|
|
|
|
|
|
// Scroll the highlighted row into view.
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!open || !listRef.current) return;
|
|
|
|
|
const el = listRef.current.querySelector<HTMLElement>(`[data-idx="${highlight}"]`);
|
|
|
|
|
el?.scrollIntoView({ block: "nearest" });
|
|
|
|
|
}, [highlight, open]);
|
|
|
|
|
|
|
|
|
|
async function book(id: string) {
|
|
|
|
|
setBusy(true);
|
|
|
|
|
setError(null);
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch(`/api/invoice-lines/${lineId}/book`, {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
body: JSON.stringify({ assetId: id }),
|
|
|
|
|
});
|
|
|
|
|
const data = await res.json().catch(() => ({ ok: false, error: "Unexpected response" }));
|
|
|
|
|
if (!data.ok) setError(data.error || "Could not book it");
|
|
|
|
|
else {
|
|
|
|
|
setOpen(false);
|
|
|
|
|
setQuery("");
|
|
|
|
|
router.refresh();
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
setError("Could not reach the server");
|
|
|
|
|
} finally {
|
|
|
|
|
setBusy(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function onKeyDown(e: React.KeyboardEvent) {
|
|
|
|
|
if (e.key === "Escape") {
|
|
|
|
|
setOpen(false);
|
|
|
|
|
setQuery("");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (e.key === "Enter") {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
const pick = groups.flat[highlight];
|
|
|
|
|
if (pick) book(pick.id);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
if (!open) {
|
|
|
|
|
setOpen(true);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const n = groups.flat.length;
|
|
|
|
|
if (!n) return;
|
|
|
|
|
setHighlight((h) => (e.key === "ArrowDown" ? (h + 1) % n : (h - 1 + n) % n));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div ref={boxRef} className="relative min-w-[200px]">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<div className="relative flex-1">
|
|
|
|
|
<Search className="w-3.5 h-3.5 absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none" />
|
|
|
|
|
<input
|
|
|
|
|
ref={inputRef}
|
|
|
|
|
type="text"
|
|
|
|
|
role="combobox"
|
|
|
|
|
aria-expanded={open}
|
|
|
|
|
aria-autocomplete="list"
|
|
|
|
|
aria-controls={`machine-list-${lineId}`}
|
|
|
|
|
value={open ? query : selected ? `${selected.code} · ${selected.name}` : ""}
|
|
|
|
|
placeholder="Search machines…"
|
|
|
|
|
onFocus={() => setOpen(true)}
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
setQuery(e.target.value);
|
|
|
|
|
setOpen(true);
|
|
|
|
|
setHighlight(0);
|
|
|
|
|
}}
|
|
|
|
|
onKeyDown={onKeyDown}
|
|
|
|
|
className={`min-h-[44px] w-full pl-8 pr-8 rounded-lg border text-sm transition focus:outline-none focus:border-[#30638E] ${
|
|
|
|
|
selected
|
|
|
|
|
? "border-[#00798C]/30 bg-[#00798C]/5 text-[#003D5B] dark:text-white font-medium"
|
|
|
|
|
: "border-[#D1495B]/40 bg-[#D1495B]/5 text-[#D1495B]"
|
|
|
|
|
}`}
|
|
|
|
|
/>
|
|
|
|
|
{busy ? (
|
|
|
|
|
<Loader2 className="w-4 h-4 animate-spin text-[#00798C] absolute right-2.5 top-1/2 -translate-y-1/2" />
|
|
|
|
|
) : (
|
|
|
|
|
<ChevronDown
|
|
|
|
|
className={`w-4 h-4 absolute right-2.5 top-1/2 -translate-y-1/2 text-gray-400 transition-transform pointer-events-none ${
|
|
|
|
|
open ? "rotate-180" : ""
|
|
|
|
|
}`}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
{selected && !busy && (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => book("")}
|
|
|
|
|
aria-label="Clear the machine booking"
|
|
|
|
|
className="grid place-items-center w-11 h-11 rounded-lg text-gray-500 hover:bg-gray-100 dark:hover:bg-white/5 transition shrink-0"
|
|
|
|
|
>
|
|
|
|
|
<X className="w-4 h-4" />
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
Fix: toast and machine dropdown were rendering behind the page chrome
Two different causes, both from the same root — the header was raised to
z-[1100] so the Plant Map's Leaflet controls (z-1000) could not scroll over
it, which quietly pushed my overlays underneath it.
The toast sat at z-60 and the Add Invoice dialog at z-50, both below the
header, so a confirmation appeared to vanish behind the nav bar. Rung out
against the existing ladder instead of picking a new number: header 1100,
nav drawer 1200, dialog 1250, toast 1300. The toast and the combobox list sit
at the same layer deliberately — they never open at the same time.
The machine dropdown was a separate and worse bug: it was not merely behind
anything, it was CLIPPED. It rendered inside the line table's overflow-x-auto
container, so the list was cut off by the table edge and the card around it —
it could never escape, which is why the search appeared to do nothing.
It now renders through a portal into <body>, positioned from the input's
measured viewport rect, so no ancestor can clip it. The rect is measured
before paint so it never flashes in the wrong place, kept in sync with
scroll and resize, and the list flips upward when there is not enough room
below — which is the common case on a phone with the keyboard open.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 16:42:29 +00:00
|
|
|
{open && anchor && createPortal(
|
|
|
|
|
<div
|
Invoices: a machine chosen in the dropdown was never actually booked
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.
2026-10-03 13:43:46 +00:00
|
|
|
ref={menuRef}
|
Fix: toast and machine dropdown were rendering behind the page chrome
Two different causes, both from the same root — the header was raised to
z-[1100] so the Plant Map's Leaflet controls (z-1000) could not scroll over
it, which quietly pushed my overlays underneath it.
The toast sat at z-60 and the Add Invoice dialog at z-50, both below the
header, so a confirmation appeared to vanish behind the nav bar. Rung out
against the existing ladder instead of picking a new number: header 1100,
nav drawer 1200, dialog 1250, toast 1300. The toast and the combobox list sit
at the same layer deliberately — they never open at the same time.
The machine dropdown was a separate and worse bug: it was not merely behind
anything, it was CLIPPED. It rendered inside the line table's overflow-x-auto
container, so the list was cut off by the table edge and the card around it —
it could never escape, which is why the search appeared to do nothing.
It now renders through a portal into <body>, positioned from the input's
measured viewport rect, so no ancestor can clip it. The rect is measured
before paint so it never flashes in the wrong place, kept in sync with
scroll and resize, and the list flips upward when there is not enough room
below — which is the common case on a phone with the keyboard open.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 16:42:29 +00:00
|
|
|
style={{
|
|
|
|
|
position: "fixed",
|
|
|
|
|
top: flipUp ? Math.max(8, anchor.top - menuHeight - 6) : anchor.top + 4,
|
|
|
|
|
left: Math.min(anchor.left, window.innerWidth - menuWidth - 8),
|
|
|
|
|
width: Math.max(anchor.width, menuWidth),
|
|
|
|
|
zIndex: 1300,
|
|
|
|
|
}}
|
2026-10-03 07:20:24 +00:00
|
|
|
className="rounded-xl bg-white dark:bg-surface border border-gray-200 dark:border-white/10 shadow-2xl overflow-hidden"
|
Fix: toast and machine dropdown were rendering behind the page chrome
Two different causes, both from the same root — the header was raised to
z-[1100] so the Plant Map's Leaflet controls (z-1000) could not scroll over
it, which quietly pushed my overlays underneath it.
The toast sat at z-60 and the Add Invoice dialog at z-50, both below the
header, so a confirmation appeared to vanish behind the nav bar. Rung out
against the existing ladder instead of picking a new number: header 1100,
nav drawer 1200, dialog 1250, toast 1300. The toast and the combobox list sit
at the same layer deliberately — they never open at the same time.
The machine dropdown was a separate and worse bug: it was not merely behind
anything, it was CLIPPED. It rendered inside the line table's overflow-x-auto
container, so the list was cut off by the table edge and the card around it —
it could never escape, which is why the search appeared to do nothing.
It now renders through a portal into <body>, positioned from the input's
measured viewport rect, so no ancestor can clip it. The rect is measured
before paint so it never flashes in the wrong place, kept in sync with
scroll and resize, and the list flips upward when there is not enough room
below — which is the common case on a phone with the keyboard open.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 16:42:29 +00:00
|
|
|
>
|
Invoices: make the machine picker searchable
Booking a line meant scrolling a native <select> of 44 machines, where two
dozen near-identical "CASE TLB" entries sit next to each other. A native
select cannot be filtered, so finding TLB07 meant scrolling past most of the
list.
Replaced with a combobox that filters as you type, on the code, the name or
the category, and says how many of 44 machines matched. Still behaves like a
select rather than a text box: opening it, Up/Down to move, Enter to choose,
Escape to cancel, and a "Not booked" entry to clear.
The list stays grouped by category so the codes read the way the plant
register does, and every row is a 44px target with the list scrolling within
its own box, so it stays usable on a phone. It is an ARIA combobox —
aria-expanded, aria-autocomplete, aria-controls — and the highlighted row
scrolls into view as you arrow through it.
Booking behaviour is unchanged: still posts on selection, still rolls the
display back if the save fails, still refreshes on success.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 16:37:54 +00:00
|
|
|
<div className="px-3 py-2 text-xs text-gray-500 dark:text-gray-400 border-b border-gray-100 dark:border-white/5">
|
|
|
|
|
{groups.flat.length} of {machines.length} machines
|
2026-10-02 17:09:16 +00:00
|
|
|
{!selected && " · search a code, name or the invoice description"}
|
Invoices: make the machine picker searchable
Booking a line meant scrolling a native <select> of 44 machines, where two
dozen near-identical "CASE TLB" entries sit next to each other. A native
select cannot be filtered, so finding TLB07 meant scrolling past most of the
list.
Replaced with a combobox that filters as you type, on the code, the name or
the category, and says how many of 44 machines matched. Still behaves like a
select rather than a text box: opening it, Up/Down to move, Enter to choose,
Escape to cancel, and a "Not booked" entry to clear.
The list stays grouped by category so the codes read the way the plant
register does, and every row is a 44px target with the list scrolling within
its own box, so it stays usable on a phone. It is an ARIA combobox —
aria-expanded, aria-autocomplete, aria-controls — and the highlighted row
scrolls into view as you arrow through it.
Booking behaviour is unchanged: still posts on selection, still rolls the
display back if the save fails, still refreshes on success.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 16:37:54 +00:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{value && (
|
|
|
|
|
<ul>
|
|
|
|
|
<li>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => book("")}
|
|
|
|
|
className="w-full min-h-[44px] px-3 py-2 text-left text-sm text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-white/5 flex items-center gap-2"
|
|
|
|
|
>
|
|
|
|
|
<X className="w-3.5 h-3.5" />
|
|
|
|
|
Not booked
|
|
|
|
|
</button>
|
|
|
|
|
</li>
|
|
|
|
|
</ul>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{groups.flat.length === 0 ? (
|
|
|
|
|
<p className="px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
|
|
|
|
|
No machine matches “{query}”.
|
|
|
|
|
</p>
|
|
|
|
|
) : (
|
|
|
|
|
<ul ref={listRef} id={`machine-list-${lineId}`} className="max-h-64 overflow-y-auto py-1">
|
|
|
|
|
{groups.list.map((g) => (
|
|
|
|
|
<li key={g.category}>
|
|
|
|
|
<div className="px-3 pt-2 pb-1 text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
|
|
|
|
{g.category}
|
|
|
|
|
</div>
|
|
|
|
|
<ul>
|
|
|
|
|
{g.items.map((m) => {
|
|
|
|
|
const idx = groups.flat.indexOf(m);
|
|
|
|
|
const isSel = m.id === value;
|
|
|
|
|
return (
|
|
|
|
|
<li key={m.id}>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
data-idx={idx}
|
|
|
|
|
onMouseEnter={() => setHighlight(idx)}
|
|
|
|
|
onClick={() => book(m.id)}
|
|
|
|
|
className={`w-full min-h-[44px] px-3 py-2 text-left text-sm flex items-center gap-2 transition ${
|
|
|
|
|
idx === highlight
|
|
|
|
|
? "bg-[#00798C]/10 text-[#003D5B] dark:text-white"
|
|
|
|
|
: "text-gray-600 dark:text-gray-300"
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
<span className="font-mono text-xs font-semibold text-[#00798C] w-16 shrink-0">
|
|
|
|
|
{m.code || "—"}
|
|
|
|
|
</span>
|
|
|
|
|
<span className="truncate">{m.name}</span>
|
|
|
|
|
{isSel && <Check className="w-4 h-4 text-[#00798C] ml-auto shrink-0" />}
|
|
|
|
|
</button>
|
|
|
|
|
</li>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</ul>
|
|
|
|
|
</li>
|
|
|
|
|
))}
|
|
|
|
|
</ul>
|
|
|
|
|
)}
|
Fix: toast and machine dropdown were rendering behind the page chrome
Two different causes, both from the same root — the header was raised to
z-[1100] so the Plant Map's Leaflet controls (z-1000) could not scroll over
it, which quietly pushed my overlays underneath it.
The toast sat at z-60 and the Add Invoice dialog at z-50, both below the
header, so a confirmation appeared to vanish behind the nav bar. Rung out
against the existing ladder instead of picking a new number: header 1100,
nav drawer 1200, dialog 1250, toast 1300. The toast and the combobox list sit
at the same layer deliberately — they never open at the same time.
The machine dropdown was a separate and worse bug: it was not merely behind
anything, it was CLIPPED. It rendered inside the line table's overflow-x-auto
container, so the list was cut off by the table edge and the card around it —
it could never escape, which is why the search appeared to do nothing.
It now renders through a portal into <body>, positioned from the input's
measured viewport rect, so no ancestor can clip it. The rect is measured
before paint so it never flashes in the wrong place, kept in sync with
scroll and resize, and the list flips upward when there is not enough room
below — which is the common case on a phone with the keyboard open.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 16:42:29 +00:00
|
|
|
</div>,
|
|
|
|
|
document.body,
|
Invoices: make the machine picker searchable
Booking a line meant scrolling a native <select> of 44 machines, where two
dozen near-identical "CASE TLB" entries sit next to each other. A native
select cannot be filtered, so finding TLB07 meant scrolling past most of the
list.
Replaced with a combobox that filters as you type, on the code, the name or
the category, and says how many of 44 machines matched. Still behaves like a
select rather than a text box: opening it, Up/Down to move, Enter to choose,
Escape to cancel, and a "Not booked" entry to clear.
The list stays grouped by category so the codes read the way the plant
register does, and every row is a 44px target with the list scrolling within
its own box, so it stays usable on a phone. It is an ARIA combobox —
aria-expanded, aria-autocomplete, aria-controls — and the highlighted row
scrolls into view as you arrow through it.
Booking behaviour is unchanged: still posts on selection, still rolls the
display back if the save fails, still refreshes on success.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 16:37:54 +00:00
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{error && (
|
|
|
|
|
<p className="mt-1 flex items-start gap-1 text-sm text-[#D1495B]">
|
|
|
|
|
<AlertCircle className="w-3.5 h-3.5 mt-0.5 shrink-0" />
|
|
|
|
|
{error}
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|