pelagia-portal/App/components/ui/vendor-select.tsx
Claude (auto-fix) c503f839e8
All checks were successful
PR checks / checks (pull_request) Successful in 46s
PR checks / integration (pull_request) Successful in 31s
feat(po): make vendor field a searchable combobox
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
2026-06-24 05:16:57 +05:30

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 &ldquo;{query}&rdquo;</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>
);
}