diff --git a/inventory-server/src/routes/import.js b/inventory-server/src/routes/import.js index b298daa..6ad1d38 100644 --- a/inventory-server/src/routes/import.js +++ b/inventory-server/src/routes/import.js @@ -1058,7 +1058,16 @@ router.get('/search-products', async (req, res) => { // Build WHERE clause with additional filters let whereClause; if (pid) { - whereClause = `\n WHERE p.pid = ${connection.escape(Number(pid))}`; + const pids = String(pid).split(',').map(Number).filter(n => !isNaN(n) && n > 0); + if (pids.length === 0) { + connection.release(); + return res.status(400).json({ error: 'Invalid pid parameter' }); + } + if (pids.length === 1) { + whereClause = `\n WHERE p.pid = ${connection.escape(pids[0])}`; + } else { + whereClause = `\n WHERE p.pid IN (${pids.map(p => connection.escape(p)).join(',')})`; + } } else { whereClause = ` WHERE ( diff --git a/inventory-server/src/routes/products.js b/inventory-server/src/routes/products.js index 72f3b5e..0a1f037 100644 --- a/inventory-server/src/routes/products.js +++ b/inventory-server/src/routes/products.js @@ -473,21 +473,31 @@ router.get('/search', async (req, res) => { return [like, like, like, like, like]; }); - const { rows } = await pool.query(` - SELECT pid, title, sku, barcode, brand, line, regular_price, image_175 - FROM products p - WHERE ${conditions.join(' AND ')} - ORDER BY - CASE WHEN p.sku ILIKE $${params.length + 1} THEN 0 - WHEN p.barcode ILIKE $${params.length + 1} THEN 1 - WHEN p.title ILIKE $${params.length + 1} THEN 2 - ELSE 3 - END, - p.total_sold DESC NULLS LAST - LIMIT 50 - `, [...params, `%${q.trim()}%`]); + const whereClause = conditions.join(' AND '); + const searchParams = [...params, `%${q.trim()}%`]; - res.json(rows); + const [{ rows }, { rows: countRows }] = await Promise.all([ + pool.query(` + SELECT pid, title, sku, barcode, brand, line, regular_price, image_175 + FROM products p + WHERE ${whereClause} + ORDER BY + CASE WHEN p.sku ILIKE $${params.length + 1} THEN 0 + WHEN p.barcode ILIKE $${params.length + 1} THEN 1 + WHEN p.title ILIKE $${params.length + 1} THEN 2 + ELSE 3 + END, + p.total_sold DESC NULLS LAST + LIMIT 50 + `, searchParams), + pool.query(` + SELECT COUNT(*)::int AS total + FROM products p + WHERE ${whereClause} + `, params), + ]); + + res.json({ results: rows, total: countRows[0].total }); } catch (error) { console.error('Error searching products:', error); res.status(500).json({ error: 'Search failed' }); diff --git a/inventory/src/components/product-editor/ProductSearch.tsx b/inventory/src/components/product-editor/ProductSearch.tsx index 82903df..395cea7 100644 --- a/inventory/src/components/product-editor/ProductSearch.tsx +++ b/inventory/src/components/product-editor/ProductSearch.tsx @@ -12,10 +12,16 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { Loader2, Search } from "lucide-react"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { Check, ChevronDown, Loader2, Search } from "lucide-react"; import type { SearchProduct } from "./types"; +const SEARCH_LIMIT = 50; + interface QuickSearchResult { pid: number; title: string; @@ -29,31 +35,43 @@ interface QuickSearchResult { export function ProductSearch({ onSelect, + onLoadAll, + onNewSearch, + loadedPids, }: { onSelect: (product: SearchProduct) => void; + onLoadAll: (pids: number[]) => void; + onNewSearch: () => void; + loadedPids: Set; }) { const [searchTerm, setSearchTerm] = useState(""); const [searchResults, setSearchResults] = useState([]); + const [totalCount, setTotalCount] = useState(0); const [isSearching, setIsSearching] = useState(false); const [isLoadingProduct, setIsLoadingProduct] = useState(null); + const [resultsOpen, setResultsOpen] = useState(false); const handleSearch = useCallback(async () => { if (!searchTerm.trim()) return; setIsSearching(true); + onNewSearch(); try { const res = await axios.get("/api/products/search", { params: { q: searchTerm }, }); - setSearchResults(res.data); + setSearchResults(res.data.results); + setTotalCount(res.data.total); + setResultsOpen(true); } catch { toast.error("Search failed"); } finally { setIsSearching(false); } - }, [searchTerm]); + }, [searchTerm, onNewSearch]); const handleSelect = useCallback( async (product: QuickSearchResult) => { + if (loadedPids.has(Number(product.pid))) return; setIsLoadingProduct(product.pid); try { const res = await axios.get("/api/import/search-products", { @@ -62,7 +80,7 @@ export function ProductSearch({ const full = (res.data as SearchProduct[])[0]; if (full) { onSelect(full); - setSearchResults([]); + setResultsOpen(false); } else { toast.error("Could not load full product details"); } @@ -72,9 +90,23 @@ export function ProductSearch({ setIsLoadingProduct(null); } }, - [onSelect] + [onSelect, loadedPids] ); + const handleLoadAll = useCallback(() => { + const pids = searchResults + .map((r) => r.pid) + .filter((pid) => !loadedPids.has(Number(pid))); + if (pids.length === 0) return; + onLoadAll(pids); + setResultsOpen(false); + }, [searchResults, loadedPids, onLoadAll]); + + const unloadedCount = searchResults.filter( + (r) => !loadedPids.has(Number(r.pid)) + ).length; + const isTruncated = totalCount > SEARCH_LIMIT; + return ( @@ -98,45 +130,80 @@ export function ProductSearch({ {searchResults.length > 0 && ( -
- - - - - Name - SKU - Brand - Line - Price - - - - {searchResults.map((product) => ( - !isLoadingProduct && handleSelect(product)} - > - - {isLoadingProduct === product.pid && ( - - )} - {product.title} - - {product.sku} - {product.brand} - {product.line} - - $ - {Number(product.regular_price)?.toFixed(2) ?? - product.regular_price} - + +
+ + + + {unloadedCount > 0 && ( + + )} +
+ + +
+
+ + + Name + SKU + Brand + Line + + Price + - ))} - -
-
-
+ + + {searchResults.map((product) => { + const isLoaded = loadedPids.has(Number(product.pid)); + return ( + + !isLoadingProduct && !isLoaded && handleSelect(product) + } + > + + {isLoadingProduct === product.pid && ( + + )} + {isLoaded && ( + + )} + {product.title} + + {product.sku} + {product.brand} + {product.line} + + $ + {Number(product.regular_price)?.toFixed(2) ?? + product.regular_price} + + + ); + })} + + + + {isTruncated && ( +

+ Showing top {SEARCH_LIMIT} of {totalCount} matches. Refine your search to find specific products. +

+ )} + + )}
diff --git a/inventory/src/pages/ProductEditor.tsx b/inventory/src/pages/ProductEditor.tsx index 6c37698..90decbd 100644 --- a/inventory/src/pages/ProductEditor.tsx +++ b/inventory/src/pages/ProductEditor.tsx @@ -113,6 +113,11 @@ export default function ProductEditor() { .finally(() => setIsLoadingSublines(false)); }, [lineLine]); + const loadedPids = useMemo( + () => new Set(allProducts.map((p) => Number(p.pid))), + [allProducts] + ); + const handleSearchSelect = useCallback((product: SearchProduct) => { setAllProducts((prev) => { if (prev.some((p) => p.pid === product.pid)) return prev; @@ -121,6 +126,39 @@ export default function ProductEditor() { setPage(1); }, []); + const handleNewSearch = useCallback(() => { + setAllProducts([]); + setPage(1); + }, []); + + const handleLoadAllSearch = useCallback(async (pids: number[]) => { + const hadExisting = allProducts.length > 0; + setIsLoadingProducts(true); + try { + const res = await axios.get("/api/import/search-products", { + params: { pid: pids.join(",") }, + }); + const fetched = res.data as SearchProduct[]; + setAllProducts((prev) => { + const existingPids = new Set(prev.map((p) => p.pid)); + const newProducts = fetched.filter((p) => !existingPids.has(p.pid)); + return [...prev, ...newProducts]; + }); + setPage(1); + if (fetched.length > 1) { + toast.success( + hadExisting + ? `Loaded remaining ${fetched.length} products` + : "Loaded all products" + ); + } + } catch { + toast.error("Failed to load products"); + } finally { + setIsLoadingProducts(false); + } + }, []); + const handleRemoveProduct = useCallback((pid: number) => { setAllProducts((prev) => prev.filter((p) => p.pid !== pid)); }, []); @@ -195,6 +233,10 @@ export default function ProductEditor() { } else if (tab === "hidden" && loadedTab !== "hidden") { setLoadedTab("hidden"); loadFeedProducts("hidden-new-products", "hidden"); + } else if (tab === "search" || tab === "by-line") { + abortRef.current?.abort(); + setAllProducts([]); + setPage(1); } }, [loadedTab, loadFeedProducts, loadLandingExtras]); @@ -374,7 +416,12 @@ export default function ProductEditor() { - +