mega-plant/components/MachineCombobox.tsx
David 876619c3ac Invoices: keep the PDF on the invoice, and search the invoice's own words
Two things this question turned up.

The upload DID store the PDF, but on the intake record, never on the invoice
— so "Open invoice PDF" 404'd for every invoice imported through the app
while working for the older ones. The file now lives on invoices.source_pdf
and the intake record keeps only the provenance (who sent it, what was
parsed), rather than a second copy of the file. The document endpoint also
falls back to the intake record, which makes the seven already-stored
documents reachable again without a migration. All 10 now open.

The machine combobox searched only the register's own words, so an invoice
line reading "CB22 2.2 TON Roller Vibration" could never be matched to the
asset filed as "CAT 2,4TON RV" — the words simply were not in the list. The
line's own invoice code and description are now searchable too, so the two
sides of the same machine can actually be found from either direction.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 19:09:16 +02:00

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-[#1a222b] 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>
);
}