Enhance product page filtering
This commit is contained in:
@@ -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' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
151
inventory/src/components/ui/command.tsx
Normal file
151
inventory/src/components/ui/command.tsx
Normal 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,
|
||||
}
|
||||
@@ -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}>
|
||||
|
||||
Reference in New Issue
Block a user