The machine dropdown was still hard-coding one of four dark surface hexes that globals.css already defines as a token. Also ignore .verify/, where the Round 4 mobile checklist leaves its screenshots. They are regenerated on every pass, so they were never meant to be committed.
317 lines
12 KiB
TypeScript
317 lines
12 KiB
TypeScript
"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);
|
|
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.
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const onDown = (e: MouseEvent) => {
|
|
if (boxRef.current && !boxRef.current.contains(e.target as Node)) 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
|
|
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>
|
|
);
|
|
}
|