The vendor field on the PO forms was a plain native <select>, forcing
users to scroll the full vendor list. Mirror the item-search UX with a
searchable combobox that filters by vendor name and formal code
(vendorId), case-insensitively. The vendor list is already client-side,
so this is a pure in-memory filter — no API or DB change.
New VendorSelect component (components/ui/vendor-select.tsx) is a
self-contained portal-rendered combobox posting a hidden vendorId input,
so it drops into all three PO forms unchanged on the server:
- po/new/new-po-form
- po/[id]/edit/edit-po-form
- approvals/[id]/manager-edit-po-form
Preserves the optional field, "No vendor selected" empty option, and the
"{name} (CODE)" / "(unverified)" label. Unverified vendors (null code)
remain findable by name. Adds unit tests for the filter logic and
component behaviour.
Fixes #109
211 lines
7.4 KiB
TypeScript
211 lines
7.4 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useRef, useEffect, useLayoutEffect, useCallback } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { ChevronDown, Search, X } from "lucide-react";
|
|
|
|
export type VendorOption = { id: string; name: string; vendorId: string | null };
|
|
|
|
/**
|
|
* Filter vendors by a free-text query, matching case-insensitively against the
|
|
* vendor name and the formal code (`vendorId`). An empty/whitespace query
|
|
* returns the full list unchanged.
|
|
*/
|
|
export function filterVendors<T extends VendorOption>(vendors: T[], query: string): T[] {
|
|
const q = query.trim().toLowerCase();
|
|
if (!q) return vendors;
|
|
return vendors.filter(
|
|
(v) =>
|
|
v.name.toLowerCase().includes(q) ||
|
|
(v.vendorId ? v.vendorId.toLowerCase().includes(q) : false)
|
|
);
|
|
}
|
|
|
|
/** Label shown for a vendor: "{name} (CODE)" when verified, "{name} (unverified)" otherwise. */
|
|
export function vendorLabel(v: VendorOption): string {
|
|
return `${v.name} ${v.vendorId ? `(${v.vendorId})` : "(unverified)"}`;
|
|
}
|
|
|
|
interface Props {
|
|
name: string;
|
|
vendors: VendorOption[];
|
|
/** Initial selected vendor id (uncontrolled — the component owns its state). */
|
|
initialValue?: string;
|
|
placeholder?: string;
|
|
/** Optional callback when the selection changes. */
|
|
onChange?: (value: string) => void;
|
|
}
|
|
|
|
export function VendorSelect({
|
|
name,
|
|
vendors,
|
|
initialValue = "",
|
|
placeholder = "No vendor selected",
|
|
onChange,
|
|
}: Props) {
|
|
const [value, setValue] = useState(initialValue);
|
|
const [open, setOpen] = useState(false);
|
|
const [query, setQuery] = useState("");
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const searchRef = useRef<HTMLInputElement>(null);
|
|
const [portalStyle, setPortalStyle] = useState<React.CSSProperties>({});
|
|
const [mounted, setMounted] = useState(false);
|
|
|
|
useEffect(() => { setMounted(true); }, []);
|
|
|
|
const updatePortalPos = useCallback(() => {
|
|
if (!containerRef.current) return;
|
|
const rect = containerRef.current.getBoundingClientRect();
|
|
setPortalStyle({
|
|
position: "fixed",
|
|
top: rect.bottom + 4,
|
|
left: rect.left,
|
|
width: rect.width,
|
|
zIndex: 9999,
|
|
});
|
|
}, []);
|
|
|
|
useLayoutEffect(() => {
|
|
if (!open) return;
|
|
updatePortalPos();
|
|
}, [open, updatePortalPos]);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
window.addEventListener("scroll", updatePortalPos, true);
|
|
window.addEventListener("resize", updatePortalPos);
|
|
return () => {
|
|
window.removeEventListener("scroll", updatePortalPos, true);
|
|
window.removeEventListener("resize", updatePortalPos);
|
|
};
|
|
}, [open, updatePortalPos]);
|
|
|
|
// Close on outside click / Escape
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
function handleKey(e: KeyboardEvent) {
|
|
if (e.key === "Escape") { setOpen(false); setQuery(""); }
|
|
}
|
|
function handleClick(e: MouseEvent) {
|
|
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
|
setOpen(false);
|
|
setQuery("");
|
|
}
|
|
}
|
|
document.addEventListener("keydown", handleKey);
|
|
document.addEventListener("mousedown", handleClick);
|
|
return () => {
|
|
document.removeEventListener("keydown", handleKey);
|
|
document.removeEventListener("mousedown", handleClick);
|
|
};
|
|
}, [open]);
|
|
|
|
useEffect(() => {
|
|
if (open) searchRef.current?.focus();
|
|
}, [open]);
|
|
|
|
const selected = vendors.find((v) => v.id === value);
|
|
const selectedLabel = selected ? vendorLabel(selected) : "";
|
|
|
|
const filtered = filterVendors(vendors, query);
|
|
|
|
const select = useCallback((id: string) => {
|
|
setValue(id);
|
|
onChange?.(id);
|
|
setOpen(false);
|
|
setQuery("");
|
|
}, [onChange]);
|
|
|
|
const handleClear = useCallback((e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
setValue("");
|
|
onChange?.("");
|
|
}, [onChange]);
|
|
|
|
const dropdownPanel = (
|
|
<div
|
|
style={portalStyle}
|
|
className="rounded-lg border border-neutral-200 bg-white shadow-xl"
|
|
>
|
|
{/* Search input */}
|
|
<div className="flex items-center gap-2 p-2 border-b border-neutral-100">
|
|
<Search className="h-4 w-4 text-neutral-400 shrink-0" />
|
|
<input
|
|
ref={searchRef}
|
|
type="text"
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
placeholder="Search by name or code…"
|
|
className="flex-1 text-sm outline-none placeholder:text-neutral-400"
|
|
/>
|
|
{query && (
|
|
<button type="button" onClick={() => setQuery("")} className="text-neutral-300 hover:text-neutral-500">
|
|
<X className="h-3.5 w-3.5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Options list */}
|
|
<div className="max-h-72 overflow-y-auto overscroll-contain [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-neutral-300">
|
|
{/* "No vendor selected" empty option — always available */}
|
|
<button
|
|
type="button"
|
|
onMouseDown={(e) => { e.preventDefault(); select(""); }}
|
|
className={`w-full text-left px-3 py-2 text-sm hover:bg-primary-50 transition-colors
|
|
${value === "" ? "bg-primary-50 text-primary-700 font-medium" : "text-neutral-500"}`}
|
|
>
|
|
No vendor selected
|
|
</button>
|
|
{filtered.length === 0 ? (
|
|
<p className="px-3 py-5 text-sm text-center text-neutral-400">No vendors match “{query}”</p>
|
|
) : (
|
|
filtered.map((v) => (
|
|
<button
|
|
key={v.id}
|
|
type="button"
|
|
onMouseDown={(e) => { e.preventDefault(); select(v.id); }}
|
|
className={`w-full text-left flex items-baseline gap-2.5 px-3 py-2 text-sm hover:bg-primary-50 transition-colors
|
|
${value === v.id ? "bg-primary-50 text-primary-700 font-medium" : "text-neutral-800"}`}
|
|
>
|
|
<span className="flex-1 leading-snug">{v.name}</span>
|
|
<span className="font-mono text-xs text-neutral-400 shrink-0">
|
|
{v.vendorId ?? "unverified"}
|
|
</span>
|
|
</button>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<div ref={containerRef} className="relative w-full">
|
|
<input type="hidden" name={name} value={value} />
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpen((v) => !v)}
|
|
className={`w-full flex items-center justify-between gap-2 rounded-lg border
|
|
${open ? "border-primary-500 ring-2 ring-primary-500/20" : "border-neutral-300"}
|
|
bg-white text-left transition-colors px-3 py-2.5 text-sm
|
|
focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20`}
|
|
>
|
|
<span className={`truncate flex-1 min-w-0 ${selectedLabel ? "text-neutral-900" : "text-neutral-400"}`}>
|
|
{selectedLabel || placeholder}
|
|
</span>
|
|
<span className="flex items-center gap-1 shrink-0">
|
|
{value && (
|
|
<span role="button" tabIndex={0} onClick={handleClear}
|
|
onKeyDown={(e) => e.key === "Enter" && handleClear(e as unknown as React.MouseEvent)}
|
|
className="text-neutral-300 hover:text-neutral-500 transition-colors">
|
|
<X className="h-4 w-4" />
|
|
</span>
|
|
)}
|
|
<ChevronDown className={`text-neutral-400 transition-transform h-4 w-4 ${open ? "rotate-180" : ""}`} />
|
|
</span>
|
|
</button>
|
|
|
|
{open && mounted && createPortal(dropdownPanel, document.body)}
|
|
</div>
|
|
);
|
|
}
|