fix: On new PO screen Vendors should have search #110

Merged
shad0w merged 1 commit from claude/issue-109 into master 2026-06-23 23:52:48 +00:00
5 changed files with 333 additions and 30 deletions

View file

@ -8,6 +8,7 @@ import type { LineItemInput } from "@/lib/validations/po";
import type { Vendor, PurchaseOrder } from "@prisma/client"; import type { Vendor, PurchaseOrder } from "@prisma/client";
import type { VesselOption, AccountGroup, CompanyOption } from "@/app/(portal)/po/new/new-po-form"; import type { VesselOption, AccountGroup, CompanyOption } from "@/app/(portal)/po/new/new-po-form";
import { SearchableSelect } from "@/components/ui/searchable-select"; import { SearchableSelect } from "@/components/ui/searchable-select";
import { VendorSelect } from "@/components/ui/vendor-select";
import { DeliveryLocationField } from "@/components/po/delivery-location-field"; import { DeliveryLocationField } from "@/components/po/delivery-location-field";
import { PoTermsEditor } from "@/components/po/po-terms-editor"; import { PoTermsEditor } from "@/components/po/po-terms-editor";
import type { CatalogueCategory, PoTerm } from "@/lib/terms"; import type { CatalogueCategory, PoTerm } from "@/lib/terms";
@ -244,14 +245,7 @@ export function ManagerEditPoForm({ po, vessels, accounts, vendors, companies, d
<section> <section>
<h3 className="text-xs font-bold text-amber-800 uppercase tracking-wider mb-3">Vendor</h3> <h3 className="text-xs font-bold text-amber-800 uppercase tracking-wider mb-3">Vendor</h3>
<label className={LABEL}>Vendor</label> <label className={LABEL}>Vendor</label>
<select name="vendorId" defaultValue={po.vendorId ?? ""} className={INPUT}> <VendorSelect name="vendorId" vendors={vendors} initialValue={po.vendorId ?? ""} />
<option value="">No vendor selected</option>
{vendors.map((v) => (
<option key={v.id} value={v.id}>
{v.name} {v.vendorId ? `(${v.vendorId})` : "(unverified)"}
</option>
))}
</select>
</section> </section>
{/* Line Items */} {/* Line Items */}

View file

@ -7,6 +7,7 @@ import type { Vendor, PurchaseOrder } from "@prisma/client";
import type { VesselOption, AccountGroup, CompanyOption } from "@/app/(portal)/po/new/new-po-form"; import type { VesselOption, AccountGroup, CompanyOption } from "@/app/(portal)/po/new/new-po-form";
import { LineItemsEditor } from "@/components/po/po-line-items-editor"; import { LineItemsEditor } from "@/components/po/po-line-items-editor";
import { SearchableSelect } from "@/components/ui/searchable-select"; import { SearchableSelect } from "@/components/ui/searchable-select";
import { VendorSelect } from "@/components/ui/vendor-select";
import { DeliveryLocationField } from "@/components/po/delivery-location-field"; import { DeliveryLocationField } from "@/components/po/delivery-location-field";
import { PoTermsEditor } from "@/components/po/po-terms-editor"; import { PoTermsEditor } from "@/components/po/po-terms-editor";
import type { CatalogueCategory, PoTerm } from "@/lib/terms"; import type { CatalogueCategory, PoTerm } from "@/lib/terms";
@ -255,14 +256,7 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, delivery
{/* Vendor */} {/* Vendor */}
<section className="rounded-lg border border-neutral-200 bg-white p-6"> <section className="rounded-lg border border-neutral-200 bg-white p-6">
<h2 className="text-base font-semibold text-neutral-900 mb-4">Vendor</h2> <h2 className="text-base font-semibold text-neutral-900 mb-4">Vendor</h2>
<select name="vendorId" defaultValue={po.vendorId ?? ""} className={INPUT_CLS}> <VendorSelect name="vendorId" vendors={vendors} initialValue={po.vendorId ?? ""} />
<option value="">No vendor selected</option>
{vendors.map((v) => (
<option key={v.id} value={v.id}>
{v.name} {v.vendorId ? `(${v.vendorId})` : "(unverified)"}
</option>
))}
</select>
</section> </section>
{/* Terms & Conditions */} {/* Terms & Conditions */}

View file

@ -7,6 +7,7 @@ import type { Vendor } from "@prisma/client";
import { LineItemsEditor } from "@/components/po/po-line-items-editor"; import { LineItemsEditor } from "@/components/po/po-line-items-editor";
import { FileUploader } from "@/components/po/file-uploader"; import { FileUploader } from "@/components/po/file-uploader";
import { SearchableSelect } from "@/components/ui/searchable-select"; import { SearchableSelect } from "@/components/ui/searchable-select";
import { VendorSelect } from "@/components/ui/vendor-select";
import { DeliveryLocationField } from "@/components/po/delivery-location-field"; import { DeliveryLocationField } from "@/components/po/delivery-location-field";
import { PoTermsEditor } from "@/components/po/po-terms-editor"; import { PoTermsEditor } from "@/components/po/po-terms-editor";
import type { CatalogueCategory, PoTerm } from "@/lib/terms"; import type { CatalogueCategory, PoTerm } from "@/lib/terms";
@ -41,7 +42,6 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
const [lineItems, setLineItems] = useState<LineItemInput[]>( const [lineItems, setLineItems] = useState<LineItemInput[]>(
initialLineItems && initialLineItems.length > 0 ? initialLineItems : [EMPTY_LINE] initialLineItems && initialLineItems.length > 0 ? initialLineItems : [EMPTY_LINE]
); );
const [vendorId, setVendorId] = useState(initialVendorId ?? "");
const [files, setFiles] = useState<File[]>([]); const [files, setFiles] = useState<File[]>([]);
const [submitting, setSubmitting] = useState<"draft" | "submit" | null>(null); const [submitting, setSubmitting] = useState<"draft" | "submit" | null>(null);
const [error, setError] = useState(""); const [error, setError] = useState("");
@ -229,19 +229,7 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
<label className="block text-sm font-medium text-neutral-700 mb-1.5"> <label className="block text-sm font-medium text-neutral-700 mb-1.5">
Vendor (optional can be added later) Vendor (optional can be added later)
</label> </label>
<select <VendorSelect name="vendorId" vendors={vendors} initialValue={initialVendorId ?? ""} />
name="vendorId"
value={vendorId}
onChange={(e) => setVendorId(e.target.value)}
className={INPUT_CLS}
>
<option value="">No vendor selected</option>
{vendors.map((v) => (
<option key={v.id} value={v.id}>
{v.name} {v.vendorId ? `(${v.vendorId})` : "(unverified)"}
</option>
))}
</select>
</div> </div>
</section> </section>

View file

@ -0,0 +1,211 @@
"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>
);
}

View file

@ -0,0 +1,116 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { filterVendors, vendorLabel, VendorSelect, type VendorOption } from "@/components/ui/vendor-select";
const VENDORS: VendorOption[] = [
{ id: "1", name: "Acme Marine Supplies", vendorId: "V-1001" },
{ id: "2", name: "Bluewater Engineering", vendorId: "V-2002" },
{ id: "3", name: "Coastal Spares", vendorId: null }, // unverified — no code
{ id: "4", name: "Delta Pumps", vendorId: "ACME-99" },
];
// ── Pure filter logic ─────────────────────────────────────────────────────────
describe("filterVendors", () => {
it("returns all vendors for an empty query", () => {
expect(filterVendors(VENDORS, "")).toHaveLength(4);
});
it("returns all vendors for a whitespace-only query", () => {
expect(filterVendors(VENDORS, " ")).toHaveLength(4);
});
it("matches by name (case-insensitive)", () => {
const res = filterVendors(VENDORS, "bluewater");
expect(res.map((v) => v.id)).toEqual(["2"]);
});
it("matches by code (vendorId), case-insensitive", () => {
const res = filterVendors(VENDORS, "v-2002");
expect(res.map((v) => v.id)).toEqual(["2"]);
});
it("matches on a code substring even when the name does not contain it", () => {
// "acme" appears in vendor #1's name AND in vendor #4's code (ACME-99)
const res = filterVendors(VENDORS, "acme");
expect(res.map((v) => v.id).sort()).toEqual(["1", "4"]);
});
it("finds an unverified vendor (null code) by name only", () => {
expect(filterVendors(VENDORS, "coastal").map((v) => v.id)).toEqual(["3"]);
// ...and never crashes on the null code
expect(filterVendors(VENDORS, "9999")).toHaveLength(0);
});
it("returns no matches for an unrelated query", () => {
expect(filterVendors(VENDORS, "zzz")).toHaveLength(0);
});
});
describe("vendorLabel", () => {
it("formats a verified vendor as name (CODE)", () => {
expect(vendorLabel(VENDORS[0])).toBe("Acme Marine Supplies (V-1001)");
});
it("formats an unverified vendor as name (unverified)", () => {
expect(vendorLabel(VENDORS[2])).toBe("Coastal Spares (unverified)");
});
});
// ── Component behaviour ───────────────────────────────────────────────────────
describe("VendorSelect", () => {
it("posts a hidden vendorId input with the empty default", () => {
const { container } = render(<VendorSelect name="vendorId" vendors={VENDORS} />);
const hidden = container.querySelector('input[name="vendorId"]') as HTMLInputElement;
expect(hidden).toBeTruthy();
expect(hidden.value).toBe("");
});
it("honours initialValue", () => {
const { container } = render(<VendorSelect name="vendorId" vendors={VENDORS} initialValue="2" />);
const hidden = container.querySelector('input[name="vendorId"]') as HTMLInputElement;
expect(hidden.value).toBe("2");
// Trigger shows the formatted label
expect(screen.getByText("Bluewater Engineering (V-2002)")).toBeTruthy();
});
it("opens and filters the list by typed query, then selects a vendor", () => {
const onChange = vi.fn();
const { container } = render(<VendorSelect name="vendorId" vendors={VENDORS} onChange={onChange} />);
fireEvent.click(screen.getByRole("button"));
const search = screen.getByPlaceholderText("Search by name or code…");
fireEvent.change(search, { target: { value: "v-2002" } });
// Only the matching vendor option is in the list
expect(screen.getByText("Bluewater Engineering")).toBeTruthy();
expect(screen.queryByText("Acme Marine Supplies")).toBeNull();
fireEvent.mouseDown(screen.getByText("Bluewater Engineering"));
expect(onChange).toHaveBeenCalledWith("2");
const hidden = container.querySelector('input[name="vendorId"]') as HTMLInputElement;
expect(hidden.value).toBe("2");
});
it("keeps 'No vendor selected' selectable to clear the choice", () => {
const onChange = vi.fn();
const { container } = render(
<VendorSelect name="vendorId" vendors={VENDORS} initialValue="1" onChange={onChange} />
);
// The trigger shows the current selection's label; click it to open.
fireEvent.click(screen.getByText("Acme Marine Supplies (V-1001)"));
// The empty option is always present, even before typing
fireEvent.mouseDown(screen.getByText("No vendor selected"));
expect(onChange).toHaveBeenLastCalledWith("");
const hidden = container.querySelector('input[name="vendorId"]') as HTMLInputElement;
expect(hidden.value).toBe("");
});
it("shows an empty-state message when nothing matches", () => {
render(<VendorSelect name="vendorId" vendors={VENDORS} />);
fireEvent.click(screen.getByRole("button"));
fireEvent.change(screen.getByPlaceholderText("Search by name or code…"), {
target: { value: "zzz" },
});
expect(screen.getByText(/No vendors match/)).toBeTruthy();
});
});