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
|
// Build WHERE clause with additional filters
|
||||||
let whereClause;
|
let whereClause;
|
||||||
if (pid) {
|
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 {
|
} else {
|
||||||
whereClause = `
|
whereClause = `
|
||||||
WHERE (
|
WHERE (
|
||||||
|
|||||||
@@ -473,10 +473,14 @@ router.get('/search', async (req, res) => {
|
|||||||
return [like, like, like, like, like];
|
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
|
SELECT pid, title, sku, barcode, brand, line, regular_price, image_175
|
||||||
FROM products p
|
FROM products p
|
||||||
WHERE ${conditions.join(' AND ')}
|
WHERE ${whereClause}
|
||||||
ORDER BY
|
ORDER BY
|
||||||
CASE WHEN p.sku ILIKE $${params.length + 1} THEN 0
|
CASE WHEN p.sku ILIKE $${params.length + 1} THEN 0
|
||||||
WHEN p.barcode ILIKE $${params.length + 1} THEN 1
|
WHEN p.barcode ILIKE $${params.length + 1} THEN 1
|
||||||
@@ -485,9 +489,15 @@ router.get('/search', async (req, res) => {
|
|||||||
END,
|
END,
|
||||||
p.total_sold DESC NULLS LAST
|
p.total_sold DESC NULLS LAST
|
||||||
LIMIT 50
|
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) {
|
} catch (error) {
|
||||||
console.error('Error searching products:', error);
|
console.error('Error searching products:', error);
|
||||||
res.status(500).json({ error: 'Search failed' });
|
res.status(500).json({ error: 'Search failed' });
|
||||||
|
|||||||
@@ -12,10 +12,16 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import {
|
||||||
import { Loader2, Search } from "lucide-react";
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from "@/components/ui/collapsible";
|
||||||
|
import { Check, ChevronDown, Loader2, Search } from "lucide-react";
|
||||||
import type { SearchProduct } from "./types";
|
import type { SearchProduct } from "./types";
|
||||||
|
|
||||||
|
const SEARCH_LIMIT = 50;
|
||||||
|
|
||||||
interface QuickSearchResult {
|
interface QuickSearchResult {
|
||||||
pid: number;
|
pid: number;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -29,31 +35,43 @@ interface QuickSearchResult {
|
|||||||
|
|
||||||
export function ProductSearch({
|
export function ProductSearch({
|
||||||
onSelect,
|
onSelect,
|
||||||
|
onLoadAll,
|
||||||
|
onNewSearch,
|
||||||
|
loadedPids,
|
||||||
}: {
|
}: {
|
||||||
onSelect: (product: SearchProduct) => void;
|
onSelect: (product: SearchProduct) => void;
|
||||||
|
onLoadAll: (pids: number[]) => void;
|
||||||
|
onNewSearch: () => void;
|
||||||
|
loadedPids: Set<number>;
|
||||||
}) {
|
}) {
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
const [searchResults, setSearchResults] = useState<QuickSearchResult[]>([]);
|
const [searchResults, setSearchResults] = useState<QuickSearchResult[]>([]);
|
||||||
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
const [isSearching, setIsSearching] = useState(false);
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
const [isLoadingProduct, setIsLoadingProduct] = useState<number | null>(null);
|
const [isLoadingProduct, setIsLoadingProduct] = useState<number | null>(null);
|
||||||
|
const [resultsOpen, setResultsOpen] = useState(false);
|
||||||
|
|
||||||
const handleSearch = useCallback(async () => {
|
const handleSearch = useCallback(async () => {
|
||||||
if (!searchTerm.trim()) return;
|
if (!searchTerm.trim()) return;
|
||||||
setIsSearching(true);
|
setIsSearching(true);
|
||||||
|
onNewSearch();
|
||||||
try {
|
try {
|
||||||
const res = await axios.get("/api/products/search", {
|
const res = await axios.get("/api/products/search", {
|
||||||
params: { q: searchTerm },
|
params: { q: searchTerm },
|
||||||
});
|
});
|
||||||
setSearchResults(res.data);
|
setSearchResults(res.data.results);
|
||||||
|
setTotalCount(res.data.total);
|
||||||
|
setResultsOpen(true);
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Search failed");
|
toast.error("Search failed");
|
||||||
} finally {
|
} finally {
|
||||||
setIsSearching(false);
|
setIsSearching(false);
|
||||||
}
|
}
|
||||||
}, [searchTerm]);
|
}, [searchTerm, onNewSearch]);
|
||||||
|
|
||||||
const handleSelect = useCallback(
|
const handleSelect = useCallback(
|
||||||
async (product: QuickSearchResult) => {
|
async (product: QuickSearchResult) => {
|
||||||
|
if (loadedPids.has(Number(product.pid))) return;
|
||||||
setIsLoadingProduct(product.pid);
|
setIsLoadingProduct(product.pid);
|
||||||
try {
|
try {
|
||||||
const res = await axios.get("/api/import/search-products", {
|
const res = await axios.get("/api/import/search-products", {
|
||||||
@@ -62,7 +80,7 @@ export function ProductSearch({
|
|||||||
const full = (res.data as SearchProduct[])[0];
|
const full = (res.data as SearchProduct[])[0];
|
||||||
if (full) {
|
if (full) {
|
||||||
onSelect(full);
|
onSelect(full);
|
||||||
setSearchResults([]);
|
setResultsOpen(false);
|
||||||
} else {
|
} else {
|
||||||
toast.error("Could not load full product details");
|
toast.error("Could not load full product details");
|
||||||
}
|
}
|
||||||
@@ -72,9 +90,23 @@ export function ProductSearch({
|
|||||||
setIsLoadingProduct(null);
|
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 (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -98,29 +130,57 @@ export function ProductSearch({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{searchResults.length > 0 && (
|
{searchResults.length > 0 && (
|
||||||
<div className="mt-4 border rounded-md">
|
<Collapsible open={resultsOpen} onOpenChange={setResultsOpen} className="mt-4">
|
||||||
<ScrollArea className="max-h-80">
|
<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>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Name</TableHead>
|
<TableHead className="sticky top-0 bg-background">Name</TableHead>
|
||||||
<TableHead>SKU</TableHead>
|
<TableHead className="sticky top-0 bg-background">SKU</TableHead>
|
||||||
<TableHead>Brand</TableHead>
|
<TableHead className="sticky top-0 bg-background">Brand</TableHead>
|
||||||
<TableHead>Line</TableHead>
|
<TableHead className="sticky top-0 bg-background">Line</TableHead>
|
||||||
<TableHead className="text-right">Price</TableHead>
|
<TableHead className="sticky top-0 bg-background text-right">
|
||||||
|
Price
|
||||||
|
</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{searchResults.map((product) => (
|
{searchResults.map((product) => {
|
||||||
|
const isLoaded = loadedPids.has(Number(product.pid));
|
||||||
|
return (
|
||||||
<TableRow
|
<TableRow
|
||||||
key={product.pid}
|
key={product.pid}
|
||||||
className={`cursor-pointer hover:bg-muted/50 ${isLoadingProduct === product.pid ? "opacity-50" : ""}`}
|
className={`${isLoaded ? "opacity-50" : "cursor-pointer hover:bg-muted/50"} ${isLoadingProduct === product.pid ? "opacity-50" : ""}`}
|
||||||
onClick={() => !isLoadingProduct && handleSelect(product)}
|
onClick={() =>
|
||||||
|
!isLoadingProduct && !isLoaded && handleSelect(product)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<TableCell className="max-w-[300px] truncate">
|
<TableCell className="max-w-[300px] truncate">
|
||||||
{isLoadingProduct === product.pid && (
|
{isLoadingProduct === product.pid && (
|
||||||
<Loader2 className="h-3 w-3 animate-spin inline mr-2" />
|
<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}
|
{product.title}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{product.sku}</TableCell>
|
<TableCell>{product.sku}</TableCell>
|
||||||
@@ -132,11 +192,18 @@ export function ProductSearch({
|
|||||||
product.regular_price}
|
product.regular_price}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</ScrollArea>
|
|
||||||
</div>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -113,6 +113,11 @@ export default function ProductEditor() {
|
|||||||
.finally(() => setIsLoadingSublines(false));
|
.finally(() => setIsLoadingSublines(false));
|
||||||
}, [lineLine]);
|
}, [lineLine]);
|
||||||
|
|
||||||
|
const loadedPids = useMemo(
|
||||||
|
() => new Set(allProducts.map((p) => Number(p.pid))),
|
||||||
|
[allProducts]
|
||||||
|
);
|
||||||
|
|
||||||
const handleSearchSelect = useCallback((product: SearchProduct) => {
|
const handleSearchSelect = useCallback((product: SearchProduct) => {
|
||||||
setAllProducts((prev) => {
|
setAllProducts((prev) => {
|
||||||
if (prev.some((p) => p.pid === product.pid)) return prev;
|
if (prev.some((p) => p.pid === product.pid)) return prev;
|
||||||
@@ -121,6 +126,39 @@ export default function ProductEditor() {
|
|||||||
setPage(1);
|
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) => {
|
const handleRemoveProduct = useCallback((pid: number) => {
|
||||||
setAllProducts((prev) => prev.filter((p) => p.pid !== pid));
|
setAllProducts((prev) => prev.filter((p) => p.pid !== pid));
|
||||||
}, []);
|
}, []);
|
||||||
@@ -195,6 +233,10 @@ export default function ProductEditor() {
|
|||||||
} else if (tab === "hidden" && loadedTab !== "hidden") {
|
} else if (tab === "hidden" && loadedTab !== "hidden") {
|
||||||
setLoadedTab("hidden");
|
setLoadedTab("hidden");
|
||||||
loadFeedProducts("hidden-new-products", "hidden");
|
loadFeedProducts("hidden-new-products", "hidden");
|
||||||
|
} else if (tab === "search" || tab === "by-line") {
|
||||||
|
abortRef.current?.abort();
|
||||||
|
setAllProducts([]);
|
||||||
|
setPage(1);
|
||||||
}
|
}
|
||||||
}, [loadedTab, loadFeedProducts, loadLandingExtras]);
|
}, [loadedTab, loadFeedProducts, loadLandingExtras]);
|
||||||
|
|
||||||
@@ -374,7 +416,12 @@ export default function ProductEditor() {
|
|||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="search" className="mt-4">
|
<TabsContent value="search" className="mt-4">
|
||||||
<ProductSearch onSelect={handleSearchSelect} />
|
<ProductSearch
|
||||||
|
onSelect={handleSearchSelect}
|
||||||
|
onLoadAll={handleLoadAllSearch}
|
||||||
|
onNewSearch={handleNewSearch}
|
||||||
|
loadedPids={loadedPids}
|
||||||
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="new" className="mt-4">
|
<TabsContent value="new" className="mt-4">
|
||||||
|
|||||||
Reference in New Issue
Block a user