mega-plant/components/MachineCombobox.tsx

329 lines
12 KiB
TypeScript
Raw Permalink Normal View History

"use client";
import { useState, useRef, useEffect, useMemo, useLayoutEffect } from "react";
import { createPortal } from "react-dom";
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,
lineCode,
lineDescription,
}: {
lineId: string;
value: string;
machines: Machine[];
// 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;
}) {
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);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLUListElement>(null);
// 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);
const selected = machines.find((m) => m.id === value) || null;
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]);
// Flattened, filtered list — the flat order is what the keyboard walks.
const groups = useMemo(() => {
const q = query.trim().toLowerCase();
// 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);
};
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)),
};
}, [machines, query, lineCode, lineDescription]);
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]);
// 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.
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);
};
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>
{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}
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,
}}
className="rounded-xl bg-white dark:bg-surface border border-gray-200 dark:border-white/10 shadow-2xl overflow-hidden"
>
<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
{!selected && " · search a code, name or the invoice description"}
</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>
)}
</div>,
document.body,
)}
{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>
);
}