Product editor search enhancements
This commit is contained in:
@@ -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 (
|
||||
|
||||
@@ -473,10 +473,14 @@ router.get('/search', async (req, res) => {
|
||||
return [like, like, like, like, like];
|
||||
});
|
||||
|
||||
const { rows } = await pool.query(`
|
||||
const whereClause = conditions.join(' AND ');
|
||||
const searchParams = [...params, `%${q.trim()}%`];
|
||||
|
||||
const [{ rows }, { rows: countRows }] = await Promise.all([
|
||||
pool.query(`
|
||||
SELECT pid, title, sku, barcode, brand, line, regular_price, image_175
|
||||
FROM products p
|
||||
WHERE ${conditions.join(' AND ')}
|
||||
WHERE ${whereClause}
|
||||
ORDER BY
|
||||
CASE WHEN p.sku ILIKE $${params.length + 1} THEN 0
|
||||
WHEN p.barcode ILIKE $${params.length + 1} THEN 1
|
||||
@@ -485,9 +489,15 @@ router.get('/search', async (req, res) => {
|
||||
END,
|
||||
p.total_sold DESC NULLS LAST
|
||||
LIMIT 50
|
||||
`, [...params, `%${q.trim()}%`]);
|
||||
`, searchParams),
|
||||
pool.query(`
|
||||
SELECT COUNT(*)::int AS total
|
||||
FROM products p
|
||||
WHERE ${whereClause}
|
||||
`, params),
|
||||
]);
|
||||
|
||||
res.json(rows);
|
||||
res.json({ results: rows, total: countRows[0].total });
|
||||
} catch (error) {
|
||||
console.error('Error searching products:', error);
|
||||
res.status(500).json({ error: 'Search failed' });
|
||||
|
||||
@@ -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<number>;
|
||||
}) {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [searchResults, setSearchResults] = useState<QuickSearchResult[]>([]);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [isLoadingProduct, setIsLoadingProduct] = useState<number | null>(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 (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -98,29 +130,57 @@ export function ProductSearch({
|
||||
</div>
|
||||
|
||||
{searchResults.length > 0 && (
|
||||
<div className="mt-4 border rounded-md">
|
||||
<ScrollArea className="max-h-80">
|
||||
<Collapsible open={resultsOpen} onOpenChange={setResultsOpen} className="mt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="gap-1 px-2 text-muted-foreground">
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 transition-transform ${resultsOpen ? "" : "-rotate-90"}`}
|
||||
/>
|
||||
{isTruncated
|
||||
? `Showing ${SEARCH_LIMIT} of ${totalCount} results`
|
||||
: `${totalCount} ${totalCount === 1 ? "result" : "results"}`}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
{unloadedCount > 0 && (
|
||||
<Button variant="outline" size="sm" onClick={handleLoadAll}>
|
||||
Load all results
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CollapsibleContent>
|
||||
<div className="border rounded-md mt-2 max-h-80 overflow-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>SKU</TableHead>
|
||||
<TableHead>Brand</TableHead>
|
||||
<TableHead>Line</TableHead>
|
||||
<TableHead className="text-right">Price</TableHead>
|
||||
<TableHead className="sticky top-0 bg-background">Name</TableHead>
|
||||
<TableHead className="sticky top-0 bg-background">SKU</TableHead>
|
||||
<TableHead className="sticky top-0 bg-background">Brand</TableHead>
|
||||
<TableHead className="sticky top-0 bg-background">Line</TableHead>
|
||||
<TableHead className="sticky top-0 bg-background text-right">
|
||||
Price
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{searchResults.map((product) => (
|
||||
{searchResults.map((product) => {
|
||||
const isLoaded = loadedPids.has(Number(product.pid));
|
||||
return (
|
||||
<TableRow
|
||||
key={product.pid}
|
||||
className={`cursor-pointer hover:bg-muted/50 ${isLoadingProduct === product.pid ? "opacity-50" : ""}`}
|
||||
onClick={() => !isLoadingProduct && handleSelect(product)}
|
||||
className={`${isLoaded ? "opacity-50" : "cursor-pointer hover:bg-muted/50"} ${isLoadingProduct === product.pid ? "opacity-50" : ""}`}
|
||||
onClick={() =>
|
||||
!isLoadingProduct && !isLoaded && handleSelect(product)
|
||||
}
|
||||
>
|
||||
<TableCell className="max-w-[300px] truncate">
|
||||
{isLoadingProduct === product.pid && (
|
||||
<Loader2 className="h-3 w-3 animate-spin inline mr-2" />
|
||||
)}
|
||||
{isLoaded && (
|
||||
<Check className="h-3 w-3 inline mr-2 text-green-600" />
|
||||
)}
|
||||
{product.title}
|
||||
</TableCell>
|
||||
<TableCell>{product.sku}</TableCell>
|
||||
@@ -132,11 +192,18 @@ export function ProductSearch({
|
||||
product.regular_price}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
{isTruncated && (
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Showing top {SEARCH_LIMIT} of {totalCount} matches. Refine your search to find specific products.
|
||||
</p>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -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() {
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="search" className="mt-4">
|
||||
<ProductSearch onSelect={handleSearchSelect} />
|
||||
<ProductSearch
|
||||
onSelect={handleSearchSelect}
|
||||
onLoadAll={handleLoadAllSearch}
|
||||
onNewSearch={handleNewSearch}
|
||||
loadedPids={loadedPids}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="new" className="mt-4">
|
||||
|
||||
Reference in New Issue
Block a user