import { Combobox } from "@tailor-platform/app-shell"; import { useCallback } from "react"; import { useClient } from "urql"; import { graphql } from "@/graphql"; import { cn } from "@/lib/utils"; const ItemSearchQuery = graphql(` query ItemSearch($query: ItemQueryInput, $first: Int) { items(query: $query, first: $first) { edges { node { id name sku unitId unit { symbol } purchaseItem { id } salesItem { id } } } } } `); interface Item { id: string; name: string; sku: string | null; /** * Master UoM, always present on search results (non-null FK on the backend). * Optional here because callers reconstruct `value` from form state, which * only tracks id/name/sku. */ unitId?: string; unit?: { symbol: string } | null; } const formatItemLabel = (item: Item): string => `${item.name}${item.sku ? ` (${item.sku})` : ""}`; const DEBOUNCE_MS = 300; const PAGE_SIZE = 20; // ItemQueryInput cannot express "has a business-function record" (no relation // filters), so adoptedFor filters client-side over an oversampled page. const ADOPTION_OVERSAMPLE = 100; interface ItemComboboxProps { /** Currently selected item (controlled) */ value: Item | null; /** Called when the selected item changes */ onValueChange: (item: Item | null) => void; /** Placeholder text */ placeholder?: string; /** Additional className */ className?: string; /** Whether the combobox is disabled */ disabled?: boolean; /** Offer only items adopted for the given business function */ adoptedFor?: "purchasing" | "selling"; } /** * Async searchable combobox for selecting Items. * * Queries the backend with a debounced case-insensitive regex filter * matching either `name` or `sku`. Returns up to PAGE_SIZE results. When * the query is null (dropdown just opened, nothing typed), returns the * first PAGE_SIZE items. */ export const ItemCombobox = ({ value, onValueChange, placeholder = "Search items...", className, disabled, adoptedFor, }: ItemComboboxProps) => { const client = useClient(); const fetcher = useCallback( async (input: string | null, { signal }: { signal: AbortSignal }) => { // When the dropdown reopens with the selected label still in the input, // treat it as an unfiltered open so the user sees the full list again. const selectedLabel = value ? formatItemLabel(value) : null; const searchText = input === selectedLabel ? null : input; // Use case-insensitive regex — `contains` is case-sensitive on the backend const textFilter = searchText ? { regex: `(?i)${searchText.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}` } : undefined; const query = textFilter ? { or: [{ name: textFilter }, { sku: textFilter }] } : undefined; const result = await client .query( ItemSearchQuery, { query, first: adoptedFor ? ADOPTION_OVERSAMPLE : PAGE_SIZE }, { fetchOptions: { signal }, requestPolicy: "network-only" }, ) .toPromise(); const nodes = result.data?.items.edges.map((e) => e.node) ?? []; if (!adoptedFor) { return nodes; } return nodes .filter((node) => (adoptedFor === "purchasing" ? node.purchaseItem : node.salesItem)) .slice(0, PAGE_SIZE); }, [client, value, adoptedFor], ); return ( ({ label: formatItemLabel(item), key: item.id, })} placeholder={placeholder} className={cn("[&_[data-slot=combobox-input]]:pr-14", className)} disabled={disabled} /> ); };