diff --git a/App/app/(portal)/approvals/[id]/manager-edit-po-form.tsx b/App/app/(portal)/approvals/[id]/manager-edit-po-form.tsx index d1a76f6..e37fc46 100644 --- a/App/app/(portal)/approvals/[id]/manager-edit-po-form.tsx +++ b/App/app/(portal)/approvals/[id]/manager-edit-po-form.tsx @@ -8,6 +8,7 @@ import type { LineItemInput } from "@/lib/validations/po"; import type { Vendor, PurchaseOrder } from "@prisma/client"; import type { VesselOption, AccountGroup, CompanyOption } from "@/app/(portal)/po/new/new-po-form"; import { SearchableSelect } from "@/components/ui/searchable-select"; +import { VendorSelect } from "@/components/ui/vendor-select"; import { DeliveryLocationField } from "@/components/po/delivery-location-field"; import { PoTermsEditor } from "@/components/po/po-terms-editor"; import type { CatalogueCategory, PoTerm } from "@/lib/terms"; @@ -244,14 +245,7 @@ export function ManagerEditPoForm({ po, vessels, accounts, vendors, companies, d

Vendor

- +
{/* Line Items */} diff --git a/App/app/(portal)/po/[id]/edit/edit-po-form.tsx b/App/app/(portal)/po/[id]/edit/edit-po-form.tsx index 6ef1c55..fb03245 100644 --- a/App/app/(portal)/po/[id]/edit/edit-po-form.tsx +++ b/App/app/(portal)/po/[id]/edit/edit-po-form.tsx @@ -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 { LineItemsEditor } from "@/components/po/po-line-items-editor"; import { SearchableSelect } from "@/components/ui/searchable-select"; +import { VendorSelect } from "@/components/ui/vendor-select"; import { DeliveryLocationField } from "@/components/po/delivery-location-field"; import { PoTermsEditor } from "@/components/po/po-terms-editor"; import type { CatalogueCategory, PoTerm } from "@/lib/terms"; @@ -255,14 +256,7 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, delivery {/* Vendor */}

Vendor

- +
{/* Terms & Conditions */} diff --git a/App/app/(portal)/po/new/new-po-form.tsx b/App/app/(portal)/po/new/new-po-form.tsx index e44c0a3..d8aca40 100644 --- a/App/app/(portal)/po/new/new-po-form.tsx +++ b/App/app/(portal)/po/new/new-po-form.tsx @@ -7,6 +7,7 @@ import type { Vendor } from "@prisma/client"; import { LineItemsEditor } from "@/components/po/po-line-items-editor"; import { FileUploader } from "@/components/po/file-uploader"; import { SearchableSelect } from "@/components/ui/searchable-select"; +import { VendorSelect } from "@/components/ui/vendor-select"; import { DeliveryLocationField } from "@/components/po/delivery-location-field"; import { PoTermsEditor } from "@/components/po/po-terms-editor"; import type { CatalogueCategory, PoTerm } from "@/lib/terms"; @@ -41,7 +42,6 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio const [lineItems, setLineItems] = useState( initialLineItems && initialLineItems.length > 0 ? initialLineItems : [EMPTY_LINE] ); - const [vendorId, setVendorId] = useState(initialVendorId ?? ""); const [files, setFiles] = useState([]); const [submitting, setSubmitting] = useState<"draft" | "submit" | null>(null); const [error, setError] = useState(""); @@ -229,19 +229,7 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio - + diff --git a/App/components/ui/vendor-select.tsx b/App/components/ui/vendor-select.tsx new file mode 100644 index 0000000..dbfcf30 --- /dev/null +++ b/App/components/ui/vendor-select.tsx @@ -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(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(null); + const searchRef = useRef(null); + const [portalStyle, setPortalStyle] = useState({}); + 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 = ( +
+ {/* Search input */} +
+ + setQuery(e.target.value)} + placeholder="Search by name or code…" + className="flex-1 text-sm outline-none placeholder:text-neutral-400" + /> + {query && ( + + )} +
+ + {/* Options list */} +
+ {/* "No vendor selected" empty option — always available */} + + {filtered.length === 0 ? ( +

No vendors match “{query}”

+ ) : ( + filtered.map((v) => ( + + )) + )} +
+
+ ); + + return ( +
+ + + + + {open && mounted && createPortal(dropdownPanel, document.body)} +
+ ); +} diff --git a/App/tests/unit/vendor-select.test.tsx b/App/tests/unit/vendor-select.test.tsx new file mode 100644 index 0000000..4c9ffb5 --- /dev/null +++ b/App/tests/unit/vendor-select.test.tsx @@ -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(); + const hidden = container.querySelector('input[name="vendorId"]') as HTMLInputElement; + expect(hidden).toBeTruthy(); + expect(hidden.value).toBe(""); + }); + + it("honours initialValue", () => { + const { container } = render(); + 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(); + 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( + + ); + // 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(); + fireEvent.click(screen.getByRole("button")); + fireEvent.change(screen.getByPlaceholderText("Search by name or code…"), { + target: { value: "zzz" }, + }); + expect(screen.getByText(/No vendors match/)).toBeTruthy(); + }); +});