import { Combobox } from "@tailor-platform/app-shell"; import { useCallback } from "react"; import { useClient } from "urql"; import { graphql } from "@/graphql"; import { cn } from "@/lib/utils"; // Backed by the custom `listStockRollupsBySite` resolver rather than the // auto-generated `stockLevels` query. The rollup is keyed at the (item × site) // grain — it folds every StorageLocation under the source Site and every // stockType slice into one row per item, exposing `available` (AVAILABLE qty // at the site) — and accepts a server-side `search` that matches item name OR // SKU. That sidesteps the two dead ends the auto-generated query hits on this // screen: // 1. StockLevelQueryInput has no nested `item` filter, so name/SKU text // search is impossible through it — the bug this fixes (#845); and // 2. filtering StockLevel by `itemId` mismatches the relation-exposed // item.id format (#669), so an items-first → `itemId: { in }` approach // silently returns nothing. const SourceStockSearchQuery = graphql(` query SourceStockSearch($siteId: String, $search: String, $first: Int) { listStockRollupsBySite(siteId: $siteId, search: $search, availableOnly: true, first: $first) { edges { node { itemId itemSku itemName available } } } } `); interface SourceStockItem { id: string; name: string; sku: string | null; /** * AVAILABLE quantity at the source Site (display only). This is the * transferable balance — deliberately AVAILABLE, not on-hand * (AVAILABLE + BLOCKED) — so the label says "available", not "on hand". */ available?: string | null; } const formatAvailable = (raw: string): string => { const n = Number(raw); return Number.isFinite(n) ? n.toLocaleString() : raw; }; const formatLabel = (it: SourceStockItem): string => { const base = `${it.name}${it.sku ? ` (${it.sku})` : ""}`; return it.available !== undefined && it.available !== null ? `${base} — ${formatAvailable(it.available)} available` : base; }; const DEBOUNCE_MS = 300; const PAGE_SIZE = 30; interface SourceStockItemComboboxProps { /** * Source Site — items are scoped to those with AVAILABLE stock at the Site. * `listStockRollupsBySite` is keyed on Site directly, so no Site → * StorageLocation fan-out is needed here. */ sourceSiteId: string | null; value: SourceStockItem | null; onValueChange: (item: SourceStockItem | null) => void; placeholder?: string; className?: string; disabled?: boolean; } /** * Async searchable combobox for selecting an Item that has AVAILABLE stock at * the given source Site. Backed by the `listStockRollupsBySite` rollup so * callers can only pick items actually available to transfer from the chosen * source, and can search by item name or SKU as they type. * * Disabled until a source Site is selected. */ export const SourceStockItemCombobox = ({ sourceSiteId, value, onValueChange, placeholder = "Search items in stock at source...", className, disabled, }: SourceStockItemComboboxProps) => { const client = useClient(); const fetcher = useCallback( async (input: string | null, { signal }: { signal: AbortSignal }) => { if (!sourceSiteId) return []; // When an item is selected the input shows its formatted label; don't // feed that label back as a search term (otherwise reopening the box // would search for "name (sku) — N available" and match nothing). const selectedLabel = value ? formatLabel(value) : null; const searchText = input && input !== selectedLabel ? input.trim() : ""; const result = await client .query( SourceStockSearchQuery, { siteId: sourceSiteId, search: searchText || undefined, first: PAGE_SIZE, }, { fetchOptions: { signal }, requestPolicy: "network-only" }, ) .toPromise(); // The rollup collapses StorageLocations and stockType slices into one row // per item with a site-level `available` total, so no client-side de-dup // is needed. `availableOnly: true` makes the server return only items with // AVAILABLE > 0 and exclude the rest BEFORE its page window — so the picker // never wastes page slots on (or hides available items behind) // depleted / blocked-only / in-transit-only rows. const items: SourceStockItem[] = []; for (const edge of result.data?.listStockRollupsBySite.edges ?? []) { const node = edge.node; items.push({ id: node.itemId, name: node.itemName, sku: node.itemSku, available: node.available, }); } return items; }, [client, value, sourceSiteId], ); return ( ({ label: formatLabel(it), key: it.id })} placeholder={sourceSiteId ? placeholder : "Select a source first"} className={cn("[&_[data-slot=combobox-input]]:pr-14", className)} disabled={(disabled ?? false) || !sourceSiteId} /> ); };