Enhance product page filtering

This commit is contained in:
2025-01-13 12:11:51 -05:00
parent dd882490c8
commit fffc0e759c
4 changed files with 556 additions and 290 deletions

View File

@@ -13,12 +13,6 @@ router.get('/', async (req, res) => {
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 50;
const offset = (page - 1) * limit;
const search = req.query.search || '';
const category = req.query.category || 'all';
const vendor = req.query.vendor || 'all';
const stockStatus = req.query.stockStatus || 'all';
const minPrice = parseFloat(req.query.minPrice) || 0;
const maxPrice = req.query.maxPrice ? parseFloat(req.query.maxPrice) : null;
const sortColumn = req.query.sortColumn || 'title';
const sortDirection = req.query.sortDirection === 'desc' ? 'DESC' : 'ASC';
@@ -26,12 +20,19 @@ router.get('/', async (req, res) => {
const conditions = ['p.visible = true'];
const params = [];
if (search) {
// Handle text search filters
if (req.query.search) {
conditions.push('(p.title LIKE ? OR p.SKU LIKE ?)');
params.push(`%${search}%`, `%${search}%`);
params.push(`%${req.query.search}%`, `%${req.query.search}%`);
}
if (category !== 'all') {
if (req.query.sku) {
conditions.push('p.SKU LIKE ?');
params.push(`%${req.query.sku}%`);
}
// Handle select filters
if (req.query.category && req.query.category !== 'all') {
conditions.push(`
p.product_id IN (
SELECT pc.product_id
@@ -40,99 +41,144 @@ router.get('/', async (req, res) => {
WHERE c.name = ?
)
`);
params.push(category);
params.push(req.query.category);
}
if (vendor !== 'all') {
if (req.query.vendor && req.query.vendor !== 'all') {
conditions.push('p.vendor = ?');
params.push(vendor);
params.push(req.query.vendor);
}
if (stockStatus !== 'all') {
switch (stockStatus) {
case 'out_of_stock':
conditions.push('p.stock_quantity = 0');
break;
case 'low_stock':
conditions.push('p.stock_quantity > 0 AND p.stock_quantity <= 5');
break;
case 'in_stock':
conditions.push('p.stock_quantity > 5');
break;
}
if (req.query.brand && req.query.brand !== 'all') {
conditions.push('p.brand = ?');
params.push(req.query.brand);
}
if (minPrice > 0) {
if (req.query.abcClass) {
conditions.push('pm.abc_class = ?');
params.push(req.query.abcClass);
}
// Handle numeric range filters
if (req.query.minStock) {
conditions.push('p.stock_quantity >= ?');
params.push(parseFloat(req.query.minStock));
}
if (req.query.maxStock) {
conditions.push('p.stock_quantity <= ?');
params.push(parseFloat(req.query.maxStock));
}
if (req.query.minPrice) {
conditions.push('p.price >= ?');
params.push(minPrice);
params.push(parseFloat(req.query.minPrice));
}
if (maxPrice) {
if (req.query.maxPrice) {
conditions.push('p.price <= ?');
params.push(maxPrice);
params.push(parseFloat(req.query.maxPrice));
}
if (req.query.minSalesAvg) {
conditions.push('pm.daily_sales_avg >= ?');
params.push(parseFloat(req.query.minSalesAvg));
}
if (req.query.maxSalesAvg) {
conditions.push('pm.daily_sales_avg <= ?');
params.push(parseFloat(req.query.maxSalesAvg));
}
if (req.query.minMargin) {
conditions.push('pm.avg_margin_percent >= ?');
params.push(parseFloat(req.query.minMargin));
}
if (req.query.maxMargin) {
conditions.push('pm.avg_margin_percent <= ?');
params.push(parseFloat(req.query.maxMargin));
}
if (req.query.minGMROI) {
conditions.push('pm.gmroi >= ?');
params.push(parseFloat(req.query.minGMROI));
}
if (req.query.maxGMROI) {
conditions.push('pm.gmroi <= ?');
params.push(parseFloat(req.query.maxGMROI));
}
// Handle status filters
if (req.query.stockStatus && req.query.stockStatus !== 'all') {
conditions.push('pm.stock_status = ?');
params.push(req.query.stockStatus);
}
// Get total count for pagination
const [countResult] = await pool.query(
`SELECT COUNT(*) as total FROM products p WHERE ${conditions.join(' AND ')}`,
`SELECT COUNT(DISTINCT p.product_id) as total
FROM products p
LEFT JOIN product_metrics pm ON p.product_id = pm.product_id
WHERE ${conditions.join(' AND ')}`,
params
);
const total = countResult[0].total;
// Get paginated results with metrics
// Get available filters
const [categories] = await pool.query(
'SELECT name FROM categories ORDER BY name'
);
const [vendors] = await pool.query(
'SELECT DISTINCT vendor FROM products WHERE visible = true AND vendor IS NOT NULL AND vendor != "" ORDER BY vendor'
);
// Main query with all fields
const query = `
WITH product_thresholds AS (
SELECT
p.product_id,
p.title,
p.SKU,
p.stock_quantity,
p.price,
p.regular_price,
p.cost_price,
p.landing_cost_price,
p.barcode,
p.vendor,
p.vendor_reference,
p.brand,
p.visible,
p.managing_stock,
p.replenishable,
p.moq,
p.uom,
p.image,
COALESCE(
(SELECT overstock_days FROM stock_thresholds st
JOIN product_categories pc ON st.category_id = pc.category_id
WHERE pc.product_id = p.product_id
AND st.vendor = p.vendor LIMIT 1),
(SELECT overstock_days FROM stock_thresholds st
JOIN product_categories pc ON st.category_id = pc.category_id
WHERE pc.product_id = p.product_id
AND st.vendor IS NULL LIMIT 1),
(SELECT overstock_days FROM stock_thresholds st
WHERE st.category_id IS NULL
AND st.vendor = p.vendor LIMIT 1),
(SELECT overstock_days FROM stock_thresholds st
WHERE st.category_id IS NULL
AND st.vendor IS NULL LIMIT 1),
90
) as target_days
FROM products p
)
SELECT
p.*,
GROUP_CONCAT(DISTINCT c.name) as categories,
-- Metrics from product_metrics
pm.daily_sales_avg,
pm.weekly_sales_avg,
pm.monthly_sales_avg,
pm.avg_quantity_per_order,
pm.number_of_orders,
pm.first_sale_date,
pm.last_sale_date,
pm.days_of_inventory,
pm.weeks_of_inventory,
pm.reorder_point,
pm.safety_stock,
pm.avg_margin_percent,
pm.total_revenue,
pm.inventory_value,
pm.cost_of_goods_sold,
pm.gross_profit,
pm.gmroi,
pm.avg_lead_time_days,
pm.last_purchase_date,
pm.last_received_date,
pm.abc_class,
pm.stock_status,
pm.turnover_rate,
pm.avg_lead_time_days,
pm.current_lead_time,
pm.target_lead_time,
pm.lead_time_status
pm.lead_time_status,
pm.days_of_inventory as days_of_stock,
COALESCE(pm.days_of_inventory / NULLIF(pt.target_days, 0), 0) as stock_coverage_ratio
FROM products p
LEFT JOIN product_metrics pm ON p.product_id = pm.product_id
LEFT JOIN product_categories pc ON p.product_id = pc.product_id
LEFT JOIN categories c ON pc.category_id = c.id
LEFT JOIN product_metrics pm ON p.product_id = pm.product_id
LEFT JOIN product_thresholds pt ON p.product_id = pt.product_id
WHERE ${conditions.join(' AND ')}
GROUP BY p.product_id
ORDER BY ${sortColumn} ${sortDirection}
@@ -141,64 +187,40 @@ router.get('/', async (req, res) => {
const [rows] = await pool.query(query, [...params, limit, offset]);
// Transform the categories string into an array and parse numeric values
const productsWithCategories = rows.map(product => ({
...product,
categories: product.categories ? [...new Set(product.categories.split(','))] : [],
// Parse numeric values
price: parseFloat(product.price) || 0,
regular_price: parseFloat(product.regular_price) || 0,
cost_price: parseFloat(product.cost_price) || 0,
landing_cost_price: parseFloat(product.landing_cost_price) || 0,
stock_quantity: parseInt(product.stock_quantity) || 0,
moq: parseInt(product.moq) || 1,
uom: parseInt(product.uom) || 1,
// Parse metrics
daily_sales_avg: parseFloat(product.daily_sales_avg) || null,
weekly_sales_avg: parseFloat(product.weekly_sales_avg) || null,
monthly_sales_avg: parseFloat(product.monthly_sales_avg) || null,
avg_quantity_per_order: parseFloat(product.avg_quantity_per_order) || null,
number_of_orders: parseInt(product.number_of_orders) || null,
days_of_inventory: parseInt(product.days_of_inventory) || null,
weeks_of_inventory: parseInt(product.weeks_of_inventory) || null,
reorder_point: parseInt(product.reorder_point) || null,
safety_stock: parseInt(product.safety_stock) || null,
avg_margin_percent: parseFloat(product.avg_margin_percent) || null,
total_revenue: parseFloat(product.total_revenue) || null,
inventory_value: parseFloat(product.inventory_value) || null,
cost_of_goods_sold: parseFloat(product.cost_of_goods_sold) || null,
gross_profit: parseFloat(product.gross_profit) || null,
gmroi: parseFloat(product.gmroi) || null,
turnover_rate: parseFloat(product.turnover_rate) || null,
avg_lead_time_days: parseInt(product.avg_lead_time_days) || null,
current_lead_time: parseInt(product.current_lead_time) || null,
target_lead_time: parseInt(product.target_lead_time) || null
// Transform the results
const products = rows.map(row => ({
...row,
categories: row.categories ? row.categories.split(',') : [],
price: parseFloat(row.price),
cost_price: parseFloat(row.cost_price),
landing_cost_price: parseFloat(row.landing_cost_price),
stock_quantity: parseInt(row.stock_quantity),
daily_sales_avg: parseFloat(row.daily_sales_avg) || 0,
weekly_sales_avg: parseFloat(row.weekly_sales_avg) || 0,
monthly_sales_avg: parseFloat(row.monthly_sales_avg) || 0,
avg_margin_percent: parseFloat(row.avg_margin_percent) || 0,
gmroi: parseFloat(row.gmroi) || 0,
lead_time_days: parseInt(row.lead_time_days) || 0,
days_of_stock: parseFloat(row.days_of_stock) || 0,
stock_coverage_ratio: parseFloat(row.stock_coverage_ratio) || 0
}));
// Get unique categories and vendors for filters
const [categories] = await pool.query(
'SELECT name FROM categories ORDER BY name'
);
const [vendors] = await pool.query(
'SELECT DISTINCT vendor FROM products WHERE visible = true AND vendor IS NOT NULL AND vendor != "" ORDER BY vendor'
);
res.json({
products: productsWithCategories,
products,
pagination: {
page,
limit,
total,
pages: Math.ceil(total / limit),
currentPage: page,
limit
totalPages: Math.ceil(total / limit)
},
filters: {
categories: categories.map(c => c.name),
vendors: vendors.map(v => v.vendor)
categories: categories.map(category => category.name),
vendors: vendors.map(vendor => vendor.vendor)
}
});
} catch (error) {
console.error('Error fetching products:', error);
res.status(500).json({ error: 'Failed to fetch products' });
res.status(500).json({ error: 'Internal server error' });
}
});

View File

@@ -1,129 +1,282 @@
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import * as React from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { X, Plus } from "lucide-react";
import { DialogTitle, DialogDescription } from "@/components/ui/dialog";
interface ProductFilters {
search: string;
category: string;
vendor: string;
stockStatus: string;
minPrice: string;
maxPrice: string;
type FilterValue = string | number | boolean;
interface FilterOption {
id: string;
label: string;
type: 'select' | 'number' | 'boolean' | 'text';
options?: { label: string; value: string }[];
group: string;
}
interface ActiveFilter {
id: string;
label: string;
value: FilterValue;
displayValue: string;
}
const FILTER_OPTIONS: FilterOption[] = [
// Basic Info Group
{ id: 'search', label: 'Search', type: 'text', group: 'Basic Info' },
{ id: 'sku', label: 'SKU', type: 'text', group: 'Basic Info' },
{ id: 'vendor', label: 'Vendor', type: 'select', group: 'Basic Info' },
{ id: 'brand', label: 'Brand', type: 'select', group: 'Basic Info' },
{ id: 'category', label: 'Category', type: 'select', group: 'Basic Info' },
// Inventory Group
{
id: 'stockStatus',
label: 'Stock Status',
type: 'select',
options: [
{ label: 'Critical', value: 'critical' },
{ label: 'Reorder', value: 'reorder' },
{ label: 'Healthy', value: 'healthy' },
{ label: 'Overstocked', value: 'overstocked' },
{ label: 'New', value: 'new' },
],
group: 'Inventory'
},
{ id: 'minStock', label: 'Min Stock', type: 'number', group: 'Inventory' },
{ id: 'maxStock', label: 'Max Stock', type: 'number', group: 'Inventory' },
// Pricing Group
{ id: 'minPrice', label: 'Min Price', type: 'number', group: 'Pricing' },
{ id: 'maxPrice', label: 'Max Price', type: 'number', group: 'Pricing' },
// Sales Metrics Group
{ id: 'minSalesAvg', label: 'Min Daily Sales Avg', type: 'number', group: 'Sales Metrics' },
{ id: 'maxSalesAvg', label: 'Max Daily Sales Avg', type: 'number', group: 'Sales Metrics' },
// Financial Metrics Group
{ id: 'minMargin', label: 'Min Margin %', type: 'number', group: 'Financial Metrics' },
{ id: 'maxMargin', label: 'Max Margin %', type: 'number', group: 'Financial Metrics' },
{ id: 'minGMROI', label: 'Min GMROI', type: 'number', group: 'Financial Metrics' },
{ id: 'maxGMROI', label: 'Max GMROI', type: 'number', group: 'Financial Metrics' },
// Classification Group
{
id: 'abcClass',
label: 'ABC Class',
type: 'select',
options: [
{ label: 'A', value: 'A' },
{ label: 'B', value: 'B' },
{ label: 'C', value: 'C' },
],
group: 'Classification'
},
];
interface ProductFiltersProps {
filters: ProductFilters;
categories: string[];
vendors: string[];
onFilterChange: (filters: Partial<ProductFilters>) => void;
onFilterChange: (filters: Record<string, FilterValue>) => void;
onClearFilters: () => void;
activeFilters: Record<string, FilterValue>;
}
export function ProductFilters({
filters,
categories,
vendors,
onFilterChange,
onClearFilters
onClearFilters,
activeFilters,
}: ProductFiltersProps) {
const activeFilterCount = Object.values(filters).filter(Boolean).length;
const [open, setOpen] = React.useState(false);
const [selectedFilter, setSelectedFilter] = React.useState<FilterOption | null>(null);
const [filterValue, setFilterValue] = React.useState<string>("");
// Update filter options with dynamic data
const filterOptions = React.useMemo(() => {
return FILTER_OPTIONS.map(option => {
if (option.id === 'category') {
return {
...option,
options: categories.map(cat => ({ label: cat, value: cat }))
};
}
if (option.id === 'vendor') {
return {
...option,
options: vendors.map(vendor => ({ label: vendor, value: vendor }))
};
}
return option;
});
}, [categories, vendors]);
const activeFiltersList = React.useMemo(() => {
if (!activeFilters) return [];
return Object.entries(activeFilters).map(([id, value]): ActiveFilter => {
const option = filterOptions.find(opt => opt.id === id);
let displayValue = String(value);
if (option?.type === 'select' && option.options) {
const optionLabel = option.options.find(opt => opt.value === value)?.label;
if (optionLabel) displayValue = optionLabel;
}
return {
id,
label: option?.label || id,
value,
displayValue
};
});
}, [activeFilters, filterOptions]);
const handleSelectFilter = (filter: FilterOption) => {
setSelectedFilter(filter);
if (filter.type === 'select') {
setOpen(false);
// Open a new command dialog for selecting the value
// This will be handled in a separate component
} else {
// For other types, we'll show an input field
setFilterValue("");
}
};
const handleRemoveFilter = (filterId: string) => {
const newFilters = { ...activeFilters };
delete newFilters[filterId];
onFilterChange(newFilters);
};
const handleApplyFilter = (value: FilterValue) => {
if (!selectedFilter) return;
const newFilters = {
...activeFilters,
[selectedFilter.id]: value
};
onFilterChange(newFilters);
setSelectedFilter(null);
setFilterValue("");
setOpen(false);
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium">Filters</h3>
{activeFilterCount > 0 && (
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="h-8 border-dashed"
onClick={() => setOpen(true)}
>
<Plus className="mr-2 h-4 w-4" />
Add Filter
</Button>
{activeFiltersList.length > 0 && (
<Button
variant="ghost"
size="sm"
className="h-8"
onClick={onClearFilters}
className="h-8 px-2 lg:px-3"
>
Clear filters
<Badge variant="secondary" className="ml-2">
{activeFilterCount}
</Badge>
Clear Filters
</Button>
)}
</div>
<div className="grid gap-4">
<div>
<Input
placeholder="Search products..."
value={filters.search}
onChange={(e) => onFilterChange({ search: e.target.value })}
className="h-8 w-full"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<Select
value={filters.category}
onValueChange={(value) => onFilterChange({ category: value })}
{activeFiltersList.length > 0 && (
<div className="flex flex-wrap gap-2">
{activeFiltersList.map((filter) => (
<Badge
key={filter.id}
variant="secondary"
className="flex items-center gap-1"
>
<SelectTrigger className="h-8 w-full">
<SelectValue placeholder="Category" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Categories</SelectItem>
{categories.map((category) => (
<SelectItem key={category} value={category}>
{category}
</SelectItem>
<span>
{filter.label}: {filter.displayValue}
</span>
<Button
variant="ghost"
size="sm"
className="h-4 w-4 p-0 hover:bg-transparent"
onClick={() => handleRemoveFilter(filter.id)}
>
<X className="h-3 w-3" />
</Button>
</Badge>
))}
</SelectContent>
</Select>
<Select
value={filters.vendor}
onValueChange={(value) => onFilterChange({ vendor: value })}
</div>
)}
<CommandDialog open={open} onOpenChange={setOpen}>
<Command className="rounded-lg border shadow-md">
<DialogTitle className="sr-only">Search Filters</DialogTitle>
<DialogDescription className="sr-only">
Search and select filters to apply to the product list
</DialogDescription>
<CommandInput placeholder="Search filters..." />
<CommandList>
<CommandEmpty>No filters found.</CommandEmpty>
{Object.entries(
filterOptions.reduce<Record<string, FilterOption[]>>((acc, filter) => {
if (!acc[filter.group]) acc[filter.group] = [];
acc[filter.group].push(filter);
return acc;
}, {})
).map(([group, filters]) => (
<CommandGroup key={group} heading={group}>
{filters.map((filter) => (
<CommandItem
key={filter.id}
onSelect={() => handleSelectFilter(filter)}
>
<SelectTrigger className="h-8 w-full">
<SelectValue placeholder="Vendor" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Vendors</SelectItem>
{vendors.map((vendor) => (
<SelectItem key={vendor} value={vendor}>
{vendor}
</SelectItem>
{filter.label}
</CommandItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<Select
value={filters.stockStatus}
onValueChange={(value) => onFilterChange({ stockStatus: value })}
</CommandGroup>
))}
</CommandList>
</Command>
</CommandDialog>
{selectedFilter?.type === 'select' && (
<CommandDialog open={!!selectedFilter} onOpenChange={() => setSelectedFilter(null)}>
<Command className="rounded-lg border shadow-md">
<DialogTitle className="sr-only">Select {selectedFilter.label}</DialogTitle>
<DialogDescription className="sr-only">
Choose a value for the {selectedFilter.label.toLowerCase()} filter
</DialogDescription>
<CommandInput placeholder={`Select ${selectedFilter.label.toLowerCase()}...`} />
<CommandList>
<CommandEmpty>No options found.</CommandEmpty>
<CommandGroup>
{selectedFilter.options?.map((option) => (
<CommandItem
key={option.value}
onSelect={() => handleApplyFilter(option.value)}
>
<SelectTrigger className="h-8 w-full">
<SelectValue placeholder="Stock Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Stock</SelectItem>
<SelectItem value="in_stock">In Stock</SelectItem>
<SelectItem value="low_stock">Low Stock</SelectItem>
<SelectItem value="out_of_stock">Out of Stock</SelectItem>
</SelectContent>
</Select>
<div className="flex items-center space-x-2">
<Input
type="number"
placeholder="Min $"
value={filters.minPrice}
onChange={(e) => onFilterChange({ minPrice: e.target.value })}
className="h-8 w-full"
/>
<span>-</span>
<Input
type="number"
placeholder="Max $"
value={filters.maxPrice}
onChange={(e) => onFilterChange({ maxPrice: e.target.value })}
className="h-8 w-full"
/>
</div>
</div>
</div>
{option.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</CommandDialog>
)}
</div>
);
}

View File

@@ -0,0 +1,151 @@
import * as React from "react"
import { type DialogProps } from "@radix-ui/react-dialog"
import { Command as CommandPrimitive } from "cmdk"
import { Search } from "lucide-react"
import { cn } from "@/lib/utils"
import { Dialog, DialogContent } from "@/components/ui/dialog"
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className
)}
{...props}
/>
))
Command.displayName = CommandPrimitive.displayName
const CommandDialog = ({ children, ...props }: DialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
))
CommandInput.displayName = CommandPrimitive.Input.displayName
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
))
CommandList.displayName = CommandPrimitive.List.displayName
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
))
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
))
CommandGroup.displayName = CommandPrimitive.Group.displayName
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
className
)}
{...props}
/>
))
CommandItem.displayName = CommandPrimitive.Item.displayName
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
CommandShortcut.displayName = "CommandShortcut"
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View File

@@ -164,14 +164,7 @@ const DEFAULT_VISIBLE_COLUMNS: (keyof Product)[] = [
export function Products() {
const queryClient = useQueryClient();
const tableRef = useRef<HTMLDivElement>(null);
const [filters, setFilters] = useState<ProductFiltersState>({
search: '',
category: 'all',
vendor: 'all',
stockStatus: 'all',
minPrice: '',
maxPrice: '',
});
const [filters, setFilters] = useState<Record<string, string | number | boolean>>({});
const [sortColumn, setSortColumn] = useState<keyof Product>('title');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
const [page, setPage] = useState(1);
@@ -215,49 +208,12 @@ export function Products() {
}
const result = await response.json();
return {
...result,
products: result.products.map((product: any) => ({
...product,
price: parseFloat(product.price) || 0,
regular_price: parseFloat(product.regular_price) || 0,
cost_price: parseFloat(product.cost_price) || 0,
stock_quantity: parseInt(product.stock_quantity) || 0
}))
};
return result;
};
const { data, isLoading, isFetching } = useQuery({
queryKey: ['products', filters, sortColumn, sortDirection, page],
queryFn: async () => {
const searchParams = new URLSearchParams({
page: page.toString(),
limit: '100',
sortColumn: sortColumn.toString(),
sortDirection,
...filters,
});
const response = await fetch(`${config.apiUrl}/products?${searchParams}`);
if (!response.ok) {
throw new Error('Network response was not ok');
}
const result = await response.json();
return {
...result,
products: result.products.map((product: any) => ({
...product,
price: parseFloat(product.price) || 0,
regular_price: parseFloat(product.regular_price) || 0,
cost_price: parseFloat(product.cost_price) || 0,
stock_quantity: parseInt(product.stock_quantity) || 0,
sku: product.SKU || product.sku || '',
image: product.image || null,
categories: Array.isArray(product.categories) ? product.categories : []
}))
};
},
queryFn: () => fetchProducts(page),
placeholderData: keepPreviousData,
staleTime: 30000,
});
@@ -312,31 +268,14 @@ export function Products() {
}
};
// Debounce the filter changes with a shorter delay
const debouncedFilterChange = useCallback(
debounce((newFilters: Partial<ProductFiltersState>) => {
setFilters(prev => ({ ...prev, ...newFilters }));
// Handle filter changes
const handleFilterChange = (newFilters: Record<string, string | number | boolean>) => {
setFilters(newFilters);
setPage(1);
}, 200), // Reduced debounce time
[]
);
const handleFilterChange = (newFilters: Partial<ProductFiltersState>) => {
// Update UI immediately for better responsiveness
setFilters(prev => ({ ...prev, ...newFilters }));
// Debounce the actual query
debouncedFilterChange(newFilters);
};
const handleClearFilters = () => {
setFilters({
search: '',
category: 'all',
vendor: 'all',
stockStatus: 'all',
minPrice: '',
maxPrice: '',
});
setFilters({});
setPage(1);
};
@@ -475,6 +414,7 @@ export function Products() {
vendors={data?.filters.vendors ?? []}
onFilterChange={handleFilterChange}
onClearFilters={handleClearFilters}
activeFilters={filters}
/>
<div ref={tableRef}>