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 page = parseInt(req.query.page) || 1;
|
||||||
const limit = parseInt(req.query.limit) || 50;
|
const limit = parseInt(req.query.limit) || 50;
|
||||||
const offset = (page - 1) * limit;
|
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 sortColumn = req.query.sortColumn || 'title';
|
||||||
const sortDirection = req.query.sortDirection === 'desc' ? 'DESC' : 'ASC';
|
const sortDirection = req.query.sortDirection === 'desc' ? 'DESC' : 'ASC';
|
||||||
|
|
||||||
@@ -26,12 +20,19 @@ router.get('/', async (req, res) => {
|
|||||||
const conditions = ['p.visible = true'];
|
const conditions = ['p.visible = true'];
|
||||||
const params = [];
|
const params = [];
|
||||||
|
|
||||||
if (search) {
|
// Handle text search filters
|
||||||
|
if (req.query.search) {
|
||||||
conditions.push('(p.title LIKE ? OR p.SKU LIKE ?)');
|
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(`
|
conditions.push(`
|
||||||
p.product_id IN (
|
p.product_id IN (
|
||||||
SELECT pc.product_id
|
SELECT pc.product_id
|
||||||
@@ -40,99 +41,144 @@ router.get('/', async (req, res) => {
|
|||||||
WHERE c.name = ?
|
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 = ?');
|
conditions.push('p.vendor = ?');
|
||||||
params.push(vendor);
|
params.push(req.query.vendor);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (stockStatus !== 'all') {
|
if (req.query.brand && req.query.brand !== 'all') {
|
||||||
switch (stockStatus) {
|
conditions.push('p.brand = ?');
|
||||||
case 'out_of_stock':
|
params.push(req.query.brand);
|
||||||
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 (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 >= ?');
|
conditions.push('p.price >= ?');
|
||||||
params.push(minPrice);
|
params.push(parseFloat(req.query.minPrice));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (maxPrice) {
|
if (req.query.maxPrice) {
|
||||||
conditions.push('p.price <= ?');
|
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
|
// Get total count for pagination
|
||||||
const [countResult] = await pool.query(
|
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
|
params
|
||||||
);
|
);
|
||||||
const total = countResult[0].total;
|
const total = countResult[0].total;
|
||||||
|
|
||||||
// Get paginated results with metrics
|
// Get available filters
|
||||||
const query = `
|
const [categories] = await pool.query(
|
||||||
SELECT
|
'SELECT name FROM categories ORDER BY name'
|
||||||
p.product_id,
|
);
|
||||||
p.title,
|
const [vendors] = await pool.query(
|
||||||
p.SKU,
|
'SELECT DISTINCT vendor FROM products WHERE visible = true AND vendor IS NOT NULL AND vendor != "" ORDER BY vendor'
|
||||||
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,
|
|
||||||
GROUP_CONCAT(DISTINCT c.name) as categories,
|
|
||||||
|
|
||||||
-- Metrics from product_metrics
|
// Main query with all fields
|
||||||
|
const query = `
|
||||||
|
WITH product_thresholds AS (
|
||||||
|
SELECT
|
||||||
|
p.product_id,
|
||||||
|
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,
|
||||||
pm.daily_sales_avg,
|
pm.daily_sales_avg,
|
||||||
pm.weekly_sales_avg,
|
pm.weekly_sales_avg,
|
||||||
pm.monthly_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.avg_margin_percent,
|
||||||
pm.total_revenue,
|
|
||||||
pm.inventory_value,
|
|
||||||
pm.cost_of_goods_sold,
|
|
||||||
pm.gross_profit,
|
|
||||||
pm.gmroi,
|
pm.gmroi,
|
||||||
pm.avg_lead_time_days,
|
|
||||||
pm.last_purchase_date,
|
|
||||||
pm.last_received_date,
|
|
||||||
pm.abc_class,
|
pm.abc_class,
|
||||||
pm.stock_status,
|
pm.stock_status,
|
||||||
pm.turnover_rate,
|
pm.avg_lead_time_days,
|
||||||
pm.current_lead_time,
|
pm.current_lead_time,
|
||||||
pm.target_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
|
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 product_categories pc ON p.product_id = pc.product_id
|
||||||
LEFT JOIN categories c ON pc.category_id = c.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 ')}
|
WHERE ${conditions.join(' AND ')}
|
||||||
GROUP BY p.product_id
|
GROUP BY p.product_id
|
||||||
ORDER BY ${sortColumn} ${sortDirection}
|
ORDER BY ${sortColumn} ${sortDirection}
|
||||||
@@ -141,64 +187,40 @@ router.get('/', async (req, res) => {
|
|||||||
|
|
||||||
const [rows] = await pool.query(query, [...params, limit, offset]);
|
const [rows] = await pool.query(query, [...params, limit, offset]);
|
||||||
|
|
||||||
// Transform the categories string into an array and parse numeric values
|
// Transform the results
|
||||||
const productsWithCategories = rows.map(product => ({
|
const products = rows.map(row => ({
|
||||||
...product,
|
...row,
|
||||||
categories: product.categories ? [...new Set(product.categories.split(','))] : [],
|
categories: row.categories ? row.categories.split(',') : [],
|
||||||
// Parse numeric values
|
price: parseFloat(row.price),
|
||||||
price: parseFloat(product.price) || 0,
|
cost_price: parseFloat(row.cost_price),
|
||||||
regular_price: parseFloat(product.regular_price) || 0,
|
landing_cost_price: parseFloat(row.landing_cost_price),
|
||||||
cost_price: parseFloat(product.cost_price) || 0,
|
stock_quantity: parseInt(row.stock_quantity),
|
||||||
landing_cost_price: parseFloat(product.landing_cost_price) || 0,
|
daily_sales_avg: parseFloat(row.daily_sales_avg) || 0,
|
||||||
stock_quantity: parseInt(product.stock_quantity) || 0,
|
weekly_sales_avg: parseFloat(row.weekly_sales_avg) || 0,
|
||||||
moq: parseInt(product.moq) || 1,
|
monthly_sales_avg: parseFloat(row.monthly_sales_avg) || 0,
|
||||||
uom: parseInt(product.uom) || 1,
|
avg_margin_percent: parseFloat(row.avg_margin_percent) || 0,
|
||||||
// Parse metrics
|
gmroi: parseFloat(row.gmroi) || 0,
|
||||||
daily_sales_avg: parseFloat(product.daily_sales_avg) || null,
|
lead_time_days: parseInt(row.lead_time_days) || 0,
|
||||||
weekly_sales_avg: parseFloat(product.weekly_sales_avg) || null,
|
days_of_stock: parseFloat(row.days_of_stock) || 0,
|
||||||
monthly_sales_avg: parseFloat(product.monthly_sales_avg) || null,
|
stock_coverage_ratio: parseFloat(row.stock_coverage_ratio) || 0
|
||||||
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
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// 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({
|
res.json({
|
||||||
products: productsWithCategories,
|
products,
|
||||||
pagination: {
|
pagination: {
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
total,
|
total,
|
||||||
pages: Math.ceil(total / limit),
|
totalPages: Math.ceil(total / limit)
|
||||||
currentPage: page,
|
|
||||||
limit
|
|
||||||
},
|
},
|
||||||
filters: {
|
filters: {
|
||||||
categories: categories.map(c => c.name),
|
categories: categories.map(category => category.name),
|
||||||
vendors: vendors.map(v => v.vendor)
|
vendors: vendors.map(vendor => vendor.vendor)
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching products:', 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 * as React from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
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 {
|
type FilterValue = string | number | boolean;
|
||||||
search: string;
|
|
||||||
category: string;
|
interface FilterOption {
|
||||||
vendor: string;
|
id: string;
|
||||||
stockStatus: string;
|
label: string;
|
||||||
minPrice: string;
|
type: 'select' | 'number' | 'boolean' | 'text';
|
||||||
maxPrice: string;
|
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 {
|
interface ProductFiltersProps {
|
||||||
filters: ProductFilters;
|
|
||||||
categories: string[];
|
categories: string[];
|
||||||
vendors: string[];
|
vendors: string[];
|
||||||
onFilterChange: (filters: Partial<ProductFilters>) => void;
|
onFilterChange: (filters: Record<string, FilterValue>) => void;
|
||||||
onClearFilters: () => void;
|
onClearFilters: () => void;
|
||||||
|
activeFilters: Record<string, FilterValue>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProductFilters({
|
export function ProductFilters({
|
||||||
filters,
|
|
||||||
categories,
|
categories,
|
||||||
vendors,
|
vendors,
|
||||||
onFilterChange,
|
onFilterChange,
|
||||||
onClearFilters
|
onClearFilters,
|
||||||
|
activeFilters,
|
||||||
}: ProductFiltersProps) {
|
}: 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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center gap-2">
|
||||||
<h3 className="text-lg font-medium">Filters</h3>
|
<Button
|
||||||
{activeFilterCount > 0 && (
|
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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
className="h-8"
|
||||||
onClick={onClearFilters}
|
onClick={onClearFilters}
|
||||||
className="h-8 px-2 lg:px-3"
|
|
||||||
>
|
>
|
||||||
Clear filters
|
Clear Filters
|
||||||
<Badge variant="secondary" className="ml-2">
|
|
||||||
{activeFilterCount}
|
|
||||||
</Badge>
|
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-4">
|
|
||||||
<div>
|
{activeFiltersList.length > 0 && (
|
||||||
<Input
|
<div className="flex flex-wrap gap-2">
|
||||||
placeholder="Search products..."
|
{activeFiltersList.map((filter) => (
|
||||||
value={filters.search}
|
<Badge
|
||||||
onChange={(e) => onFilterChange({ search: e.target.value })}
|
key={filter.id}
|
||||||
className="h-8 w-full"
|
variant="secondary"
|
||||||
/>
|
className="flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
)}
|
||||||
<Select
|
|
||||||
value={filters.category}
|
<CommandDialog open={open} onOpenChange={setOpen}>
|
||||||
onValueChange={(value) => onFilterChange({ category: value })}
|
<Command className="rounded-lg border shadow-md">
|
||||||
>
|
<DialogTitle className="sr-only">Search Filters</DialogTitle>
|
||||||
<SelectTrigger className="h-8 w-full">
|
<DialogDescription className="sr-only">
|
||||||
<SelectValue placeholder="Category" />
|
Search and select filters to apply to the product list
|
||||||
</SelectTrigger>
|
</DialogDescription>
|
||||||
<SelectContent>
|
<CommandInput placeholder="Search filters..." />
|
||||||
<SelectItem value="all">All Categories</SelectItem>
|
<CommandList>
|
||||||
{categories.map((category) => (
|
<CommandEmpty>No filters found.</CommandEmpty>
|
||||||
<SelectItem key={category} value={category}>
|
{Object.entries(
|
||||||
{category}
|
filterOptions.reduce<Record<string, FilterOption[]>>((acc, filter) => {
|
||||||
</SelectItem>
|
if (!acc[filter.group]) acc[filter.group] = [];
|
||||||
))}
|
acc[filter.group].push(filter);
|
||||||
</SelectContent>
|
return acc;
|
||||||
</Select>
|
}, {})
|
||||||
<Select
|
).map(([group, filters]) => (
|
||||||
value={filters.vendor}
|
<CommandGroup key={group} heading={group}>
|
||||||
onValueChange={(value) => onFilterChange({ vendor: value })}
|
{filters.map((filter) => (
|
||||||
>
|
<CommandItem
|
||||||
<SelectTrigger className="h-8 w-full">
|
key={filter.id}
|
||||||
<SelectValue placeholder="Vendor" />
|
onSelect={() => handleSelectFilter(filter)}
|
||||||
</SelectTrigger>
|
>
|
||||||
<SelectContent>
|
{filter.label}
|
||||||
<SelectItem value="all">All Vendors</SelectItem>
|
</CommandItem>
|
||||||
{vendors.map((vendor) => (
|
))}
|
||||||
<SelectItem key={vendor} value={vendor}>
|
</CommandGroup>
|
||||||
{vendor}
|
))}
|
||||||
</SelectItem>
|
</CommandList>
|
||||||
))}
|
</Command>
|
||||||
</SelectContent>
|
</CommandDialog>
|
||||||
</Select>
|
|
||||||
</div>
|
{selectedFilter?.type === 'select' && (
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<CommandDialog open={!!selectedFilter} onOpenChange={() => setSelectedFilter(null)}>
|
||||||
<Select
|
<Command className="rounded-lg border shadow-md">
|
||||||
value={filters.stockStatus}
|
<DialogTitle className="sr-only">Select {selectedFilter.label}</DialogTitle>
|
||||||
onValueChange={(value) => onFilterChange({ stockStatus: value })}
|
<DialogDescription className="sr-only">
|
||||||
>
|
Choose a value for the {selectedFilter.label.toLowerCase()} filter
|
||||||
<SelectTrigger className="h-8 w-full">
|
</DialogDescription>
|
||||||
<SelectValue placeholder="Stock Status" />
|
<CommandInput placeholder={`Select ${selectedFilter.label.toLowerCase()}...`} />
|
||||||
</SelectTrigger>
|
<CommandList>
|
||||||
<SelectContent>
|
<CommandEmpty>No options found.</CommandEmpty>
|
||||||
<SelectItem value="all">All Stock</SelectItem>
|
<CommandGroup>
|
||||||
<SelectItem value="in_stock">In Stock</SelectItem>
|
{selectedFilter.options?.map((option) => (
|
||||||
<SelectItem value="low_stock">Low Stock</SelectItem>
|
<CommandItem
|
||||||
<SelectItem value="out_of_stock">Out of Stock</SelectItem>
|
key={option.value}
|
||||||
</SelectContent>
|
onSelect={() => handleApplyFilter(option.value)}
|
||||||
</Select>
|
>
|
||||||
<div className="flex items-center space-x-2">
|
{option.label}
|
||||||
<Input
|
</CommandItem>
|
||||||
type="number"
|
))}
|
||||||
placeholder="Min $"
|
</CommandGroup>
|
||||||
value={filters.minPrice}
|
</CommandList>
|
||||||
onChange={(e) => onFilterChange({ minPrice: e.target.value })}
|
</Command>
|
||||||
className="h-8 w-full"
|
</CommandDialog>
|
||||||
/>
|
)}
|
||||||
<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>
|
|
||||||
</div>
|
</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() {
|
export function Products() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const tableRef = useRef<HTMLDivElement>(null);
|
const tableRef = useRef<HTMLDivElement>(null);
|
||||||
const [filters, setFilters] = useState<ProductFiltersState>({
|
const [filters, setFilters] = useState<Record<string, string | number | boolean>>({});
|
||||||
search: '',
|
|
||||||
category: 'all',
|
|
||||||
vendor: 'all',
|
|
||||||
stockStatus: 'all',
|
|
||||||
minPrice: '',
|
|
||||||
maxPrice: '',
|
|
||||||
});
|
|
||||||
const [sortColumn, setSortColumn] = useState<keyof Product>('title');
|
const [sortColumn, setSortColumn] = useState<keyof Product>('title');
|
||||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
@@ -215,49 +208,12 @@ export function Products() {
|
|||||||
}
|
}
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|
||||||
return {
|
return result;
|
||||||
...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
|
|
||||||
}))
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const { data, isLoading, isFetching } = useQuery({
|
const { data, isLoading, isFetching } = useQuery({
|
||||||
queryKey: ['products', filters, sortColumn, sortDirection, page],
|
queryKey: ['products', filters, sortColumn, sortDirection, page],
|
||||||
queryFn: async () => {
|
queryFn: () => fetchProducts(page),
|
||||||
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 : []
|
|
||||||
}))
|
|
||||||
};
|
|
||||||
},
|
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
staleTime: 30000,
|
staleTime: 30000,
|
||||||
});
|
});
|
||||||
@@ -312,31 +268,14 @@ export function Products() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Debounce the filter changes with a shorter delay
|
// Handle filter changes
|
||||||
const debouncedFilterChange = useCallback(
|
const handleFilterChange = (newFilters: Record<string, string | number | boolean>) => {
|
||||||
debounce((newFilters: Partial<ProductFiltersState>) => {
|
setFilters(newFilters);
|
||||||
setFilters(prev => ({ ...prev, ...newFilters }));
|
setPage(1);
|
||||||
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 = () => {
|
const handleClearFilters = () => {
|
||||||
setFilters({
|
setFilters({});
|
||||||
search: '',
|
|
||||||
category: 'all',
|
|
||||||
vendor: 'all',
|
|
||||||
stockStatus: 'all',
|
|
||||||
minPrice: '',
|
|
||||||
maxPrice: '',
|
|
||||||
});
|
|
||||||
setPage(1);
|
setPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -475,6 +414,7 @@ export function Products() {
|
|||||||
vendors={data?.filters.vendors ?? []}
|
vendors={data?.filters.vendors ?? []}
|
||||||
onFilterChange={handleFilterChange}
|
onFilterChange={handleFilterChange}
|
||||||
onClearFilters={handleClearFilters}
|
onClearFilters={handleClearFilters}
|
||||||
|
activeFilters={filters}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div ref={tableRef}>
|
<div ref={tableRef}>
|
||||||
|
|||||||
Reference in New Issue
Block a user