Add operators for numerical filters
This commit is contained in:
@@ -16,7 +16,6 @@ router.get('/', async (req, res) => {
|
|||||||
const sortColumn = req.query.sort || 'title';
|
const sortColumn = req.query.sort || 'title';
|
||||||
const sortDirection = req.query.order === 'desc' ? 'DESC' : 'ASC';
|
const sortDirection = req.query.order === 'desc' ? 'DESC' : 'ASC';
|
||||||
|
|
||||||
// Build the WHERE clause
|
|
||||||
const conditions = ['p.visible = true'];
|
const conditions = ['p.visible = true'];
|
||||||
const params = [];
|
const params = [];
|
||||||
|
|
||||||
@@ -25,185 +24,89 @@ router.get('/', async (req, res) => {
|
|||||||
conditions.push('p.replenishable = true');
|
conditions.push('p.replenishable = true');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle text search filters
|
// Handle search filter
|
||||||
if (req.query.search) {
|
if (req.query.search) {
|
||||||
conditions.push('(p.title LIKE ? OR p.SKU LIKE ?)');
|
conditions.push('(p.title LIKE ? OR p.SKU LIKE ? OR p.barcode LIKE ?)');
|
||||||
params.push(`%${req.query.search}%`, `%${req.query.search}%`);
|
const searchTerm = `%${req.query.search}%`;
|
||||||
|
params.push(searchTerm, searchTerm, searchTerm);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.query.sku) {
|
// Handle numeric filters with operators
|
||||||
conditions.push('p.SKU LIKE ?');
|
const numericFields = {
|
||||||
params.push(`%${req.query.sku}%`);
|
stock: 'p.stock_quantity',
|
||||||
}
|
price: 'p.price',
|
||||||
|
costPrice: 'p.cost_price',
|
||||||
|
landingCost: 'p.landing_cost_price',
|
||||||
|
dailySalesAvg: 'pm.daily_sales_avg',
|
||||||
|
weeklySalesAvg: 'pm.weekly_sales_avg',
|
||||||
|
monthlySalesAvg: 'pm.monthly_sales_avg',
|
||||||
|
margin: 'pm.avg_margin_percent',
|
||||||
|
gmroi: 'pm.gmroi',
|
||||||
|
leadTime: 'pm.current_lead_time',
|
||||||
|
stockCoverage: 'pm.days_of_inventory',
|
||||||
|
daysOfStock: 'pm.days_of_inventory'
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.entries(req.query).forEach(([key, value]) => {
|
||||||
|
const field = numericFields[key];
|
||||||
|
if (field) {
|
||||||
|
const operator = req.query[`${key}_operator`] || '=';
|
||||||
|
if (operator === 'between') {
|
||||||
|
// Handle between operator
|
||||||
|
try {
|
||||||
|
const [min, max] = JSON.parse(value);
|
||||||
|
conditions.push(`${field} BETWEEN ? AND ?`);
|
||||||
|
params.push(min, max);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Invalid between value for ${key}:`, value);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Handle other operators
|
||||||
|
conditions.push(`${field} ${operator} ?`);
|
||||||
|
params.push(parseFloat(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Handle select filters
|
// Handle select filters
|
||||||
if (req.query.category && req.query.category !== 'all') {
|
if (req.query.vendor) {
|
||||||
conditions.push(`
|
|
||||||
p.product_id IN (
|
|
||||||
SELECT pc.product_id
|
|
||||||
FROM product_categories pc
|
|
||||||
JOIN categories c ON pc.category_id = c.id
|
|
||||||
WHERE c.name = ?
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
params.push(req.query.category);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.query.vendor && req.query.vendor !== 'all') {
|
|
||||||
conditions.push('p.vendor = ?');
|
conditions.push('p.vendor = ?');
|
||||||
params.push(req.query.vendor);
|
params.push(req.query.vendor);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.query.brand && req.query.brand !== 'all') {
|
if (req.query.brand) {
|
||||||
conditions.push('p.brand = ?');
|
conditions.push('p.brand = ?');
|
||||||
params.push(req.query.brand);
|
params.push(req.query.brand);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (req.query.category) {
|
||||||
|
conditions.push('p.categories LIKE ?');
|
||||||
|
params.push(`%${req.query.category}%`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.query.stockStatus && req.query.stockStatus !== 'all') {
|
||||||
|
conditions.push('pm.stock_status = ?');
|
||||||
|
params.push(req.query.stockStatus);
|
||||||
|
}
|
||||||
|
|
||||||
if (req.query.abcClass) {
|
if (req.query.abcClass) {
|
||||||
conditions.push('pm.abc_class = ?');
|
conditions.push('pm.abc_class = ?');
|
||||||
params.push(req.query.abcClass);
|
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.daysOfStock) {
|
|
||||||
conditions.push('pm.days_of_inventory >= ?');
|
|
||||||
params.push(parseFloat(req.query.daysOfStock));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle boolean filters
|
|
||||||
if (req.query.replenishable === 'true' || req.query.replenishable === 'false') {
|
|
||||||
conditions.push('p.replenishable = ?');
|
|
||||||
params.push(req.query.replenishable === 'true');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.query.managingStock === 'true' || req.query.managingStock === 'false') {
|
|
||||||
conditions.push('p.managing_stock = ?');
|
|
||||||
params.push(req.query.managingStock === 'true');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle price filters
|
|
||||||
if (req.query.minPrice) {
|
|
||||||
conditions.push('p.price >= ?');
|
|
||||||
params.push(parseFloat(req.query.minPrice));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.query.maxPrice) {
|
|
||||||
conditions.push('p.price <= ?');
|
|
||||||
params.push(parseFloat(req.query.maxPrice));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.query.minCostPrice) {
|
|
||||||
conditions.push('p.cost_price >= ?');
|
|
||||||
params.push(parseFloat(req.query.minCostPrice));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.query.maxCostPrice) {
|
|
||||||
conditions.push('p.cost_price <= ?');
|
|
||||||
params.push(parseFloat(req.query.maxCostPrice));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.query.minLandingCost) {
|
|
||||||
conditions.push('p.landing_cost_price >= ?');
|
|
||||||
params.push(parseFloat(req.query.minLandingCost));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.query.maxLandingCost) {
|
|
||||||
conditions.push('p.landing_cost_price <= ?');
|
|
||||||
params.push(parseFloat(req.query.maxLandingCost));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle sales metrics filters
|
|
||||||
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.minWeeklySales) {
|
|
||||||
conditions.push('pm.weekly_sales_avg >= ?');
|
|
||||||
params.push(parseFloat(req.query.minWeeklySales));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.query.maxWeeklySales) {
|
|
||||||
conditions.push('pm.weekly_sales_avg <= ?');
|
|
||||||
params.push(parseFloat(req.query.maxWeeklySales));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.query.minMonthlySales) {
|
|
||||||
conditions.push('pm.monthly_sales_avg >= ?');
|
|
||||||
params.push(parseFloat(req.query.minMonthlySales));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.query.maxMonthlySales) {
|
|
||||||
conditions.push('pm.monthly_sales_avg <= ?');
|
|
||||||
params.push(parseFloat(req.query.maxMonthlySales));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle financial metrics filters
|
|
||||||
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 lead time and coverage filters
|
|
||||||
if (req.query.minLeadTime) {
|
|
||||||
conditions.push('pm.avg_lead_time_days >= ?');
|
|
||||||
params.push(parseFloat(req.query.minLeadTime));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.query.maxLeadTime) {
|
|
||||||
conditions.push('pm.avg_lead_time_days <= ?');
|
|
||||||
params.push(parseFloat(req.query.maxLeadTime));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.query.leadTimeStatus) {
|
if (req.query.leadTimeStatus) {
|
||||||
conditions.push('pm.lead_time_status = ?');
|
conditions.push('pm.lead_time_status = ?');
|
||||||
params.push(req.query.leadTimeStatus);
|
params.push(req.query.leadTimeStatus);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.query.minStockCoverage) {
|
if (req.query.replenishable !== undefined) {
|
||||||
conditions.push('(pm.days_of_inventory / pt.target_days) >= ?');
|
conditions.push('p.replenishable = ?');
|
||||||
params.push(parseFloat(req.query.minStockCoverage));
|
params.push(req.query.replenishable === 'true' ? 1 : 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.query.maxStockCoverage) {
|
if (req.query.managingStock !== undefined) {
|
||||||
conditions.push('(pm.days_of_inventory / pt.target_days) <= ?');
|
conditions.push('p.managing_stock = ?');
|
||||||
params.push(parseFloat(req.query.maxStockCoverage));
|
params.push(req.query.managingStock === 'true' ? 1 : 0);
|
||||||
}
|
|
||||||
|
|
||||||
// Handle stock status filter
|
|
||||||
if (req.query.stockStatus && req.query.stockStatus !== 'all') {
|
|
||||||
conditions.push('pm.stock_status = ?');
|
|
||||||
params.push(req.query.stockStatus);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Combine all conditions with AND
|
// Combine all conditions with AND
|
||||||
@@ -359,7 +262,7 @@ router.get('/', async (req, res) => {
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching products:', error);
|
console.error('Error fetching products:', error);
|
||||||
res.status(500).json({ error: 'Internal server error' });
|
res.status(500).json({ error: 'Failed to fetch products' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
56
inventory/package-lock.json
generated
56
inventory/package-lock.json
generated
@@ -24,6 +24,8 @@
|
|||||||
"@radix-ui/react-slot": "^1.1.1",
|
"@radix-ui/react-slot": "^1.1.1",
|
||||||
"@radix-ui/react-switch": "^1.1.2",
|
"@radix-ui/react-switch": "^1.1.2",
|
||||||
"@radix-ui/react-tabs": "^1.1.2",
|
"@radix-ui/react-tabs": "^1.1.2",
|
||||||
|
"@radix-ui/react-toggle": "^1.1.1",
|
||||||
|
"@radix-ui/react-toggle-group": "^1.1.1",
|
||||||
"@radix-ui/react-tooltip": "^1.1.6",
|
"@radix-ui/react-tooltip": "^1.1.6",
|
||||||
"@shadcn/ui": "^0.0.4",
|
"@shadcn/ui": "^0.0.4",
|
||||||
"@tanstack/react-query": "^5.63.0",
|
"@tanstack/react-query": "^5.63.0",
|
||||||
@@ -1970,6 +1972,60 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-toggle": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-i77tcgObYr743IonC1hrsnnPmszDRn8p+EGUsUt+5a/JFn28fxaM88Py6V2mc8J5kELMWishI0rLnuGLFD/nnQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.1",
|
||||||
|
"@radix-ui/react-primitive": "2.0.1",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.1.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-toggle-group": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-OgDLZEA30Ylyz8YSXvnGqIHtERqnUt1KUYTKdw/y8u7Ci6zGiJfXc02jahmcSNK3YcErqioj/9flWC9S1ihfwg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.1",
|
||||||
|
"@radix-ui/react-context": "1.1.1",
|
||||||
|
"@radix-ui/react-direction": "1.1.0",
|
||||||
|
"@radix-ui/react-primitive": "2.0.1",
|
||||||
|
"@radix-ui/react-roving-focus": "1.1.1",
|
||||||
|
"@radix-ui/react-toggle": "1.1.1",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.1.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-tooltip": {
|
"node_modules/@radix-ui/react-tooltip": {
|
||||||
"version": "1.1.6",
|
"version": "1.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.1.6.tgz",
|
||||||
|
|||||||
@@ -26,6 +26,8 @@
|
|||||||
"@radix-ui/react-slot": "^1.1.1",
|
"@radix-ui/react-slot": "^1.1.1",
|
||||||
"@radix-ui/react-switch": "^1.1.2",
|
"@radix-ui/react-switch": "^1.1.2",
|
||||||
"@radix-ui/react-tabs": "^1.1.2",
|
"@radix-ui/react-tabs": "^1.1.2",
|
||||||
|
"@radix-ui/react-toggle": "^1.1.1",
|
||||||
|
"@radix-ui/react-toggle-group": "^1.1.1",
|
||||||
"@radix-ui/react-tooltip": "^1.1.6",
|
"@radix-ui/react-tooltip": "^1.1.6",
|
||||||
"@shadcn/ui": "^0.0.4",
|
"@shadcn/ui": "^0.0.4",
|
||||||
"@tanstack/react-query": "^5.63.0",
|
"@tanstack/react-query": "^5.63.0",
|
||||||
|
|||||||
@@ -17,8 +17,28 @@ import {
|
|||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from "@/components/ui/popover";
|
} from "@/components/ui/popover";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
ToggleGroup,
|
||||||
|
ToggleGroupItem,
|
||||||
|
} from "@/components/ui/toggle-group";
|
||||||
|
|
||||||
type FilterValue = string | number | boolean;
|
type FilterValue = string | number | boolean;
|
||||||
|
type ComparisonOperator = '=' | '>' | '>=' | '<' | '<=' | 'between';
|
||||||
|
|
||||||
|
interface FilterValueWithOperator {
|
||||||
|
value: FilterValue | [number, number];
|
||||||
|
operator: ComparisonOperator;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ActiveFilterValue = FilterValue | FilterValueWithOperator;
|
||||||
|
|
||||||
|
interface ActiveFilter {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
value: ActiveFilterValue;
|
||||||
|
displayValue: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface FilterOption {
|
interface FilterOption {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -26,13 +46,7 @@ interface FilterOption {
|
|||||||
type: 'select' | 'number' | 'boolean' | 'text';
|
type: 'select' | 'number' | 'boolean' | 'text';
|
||||||
options?: { label: string; value: string }[];
|
options?: { label: string; value: string }[];
|
||||||
group: string;
|
group: string;
|
||||||
}
|
operators?: ComparisonOperator[];
|
||||||
|
|
||||||
interface ActiveFilter {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
value: FilterValue;
|
|
||||||
displayValue: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const FILTER_OPTIONS: FilterOption[] = [
|
const FILTER_OPTIONS: FilterOption[] = [
|
||||||
@@ -58,9 +72,20 @@ const FILTER_OPTIONS: FilterOption[] = [
|
|||||||
],
|
],
|
||||||
group: 'Inventory'
|
group: 'Inventory'
|
||||||
},
|
},
|
||||||
{ id: 'minStock', label: 'Min Stock', type: 'number', group: 'Inventory' },
|
{
|
||||||
{ id: 'maxStock', label: 'Max Stock', type: 'number', group: 'Inventory' },
|
id: 'stock',
|
||||||
{ id: 'daysOfStock', label: 'Days of Stock', type: 'number', group: 'Inventory' },
|
label: 'Stock Quantity',
|
||||||
|
type: 'number',
|
||||||
|
group: 'Inventory',
|
||||||
|
operators: ['=', '>', '>=', '<', '<=', 'between']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'daysOfStock',
|
||||||
|
label: 'Days of Stock',
|
||||||
|
type: 'number',
|
||||||
|
group: 'Inventory',
|
||||||
|
operators: ['=', '>', '>=', '<', '<=', 'between']
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'replenishable',
|
id: 'replenishable',
|
||||||
label: 'Replenishable',
|
label: 'Replenishable',
|
||||||
@@ -73,30 +98,75 @@ const FILTER_OPTIONS: FilterOption[] = [
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Pricing Group
|
// Pricing Group
|
||||||
{ id: 'minPrice', label: 'Min Price', type: 'number', group: 'Pricing' },
|
{
|
||||||
{ id: 'maxPrice', label: 'Max Price', type: 'number', group: 'Pricing' },
|
id: 'price',
|
||||||
{ id: 'minCostPrice', label: 'Min Cost Price', type: 'number', group: 'Pricing' },
|
label: 'Price',
|
||||||
{ id: 'maxCostPrice', label: 'Max Cost Price', type: 'number', group: 'Pricing' },
|
type: 'number',
|
||||||
{ id: 'minLandingCost', label: 'Min Landing Cost', type: 'number', group: 'Pricing' },
|
group: 'Pricing',
|
||||||
{ id: 'maxLandingCost', label: 'Max Landing Cost', type: 'number', group: 'Pricing' },
|
operators: ['=', '>', '>=', '<', '<=', 'between']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'costPrice',
|
||||||
|
label: 'Cost Price',
|
||||||
|
type: 'number',
|
||||||
|
group: 'Pricing',
|
||||||
|
operators: ['=', '>', '>=', '<', '<=', 'between']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'landingCost',
|
||||||
|
label: 'Landing Cost',
|
||||||
|
type: 'number',
|
||||||
|
group: 'Pricing',
|
||||||
|
operators: ['=', '>', '>=', '<', '<=', 'between']
|
||||||
|
},
|
||||||
|
|
||||||
// Sales Metrics Group
|
// 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' },
|
id: 'dailySalesAvg',
|
||||||
{ id: 'minWeeklySales', label: 'Min Weekly Sales Avg', type: 'number', group: 'Sales Metrics' },
|
label: 'Daily Sales Avg',
|
||||||
{ id: 'maxWeeklySales', label: 'Max Weekly Sales Avg', type: 'number', group: 'Sales Metrics' },
|
type: 'number',
|
||||||
{ id: 'minMonthlySales', label: 'Min Monthly Sales Avg', type: 'number', group: 'Sales Metrics' },
|
group: 'Sales Metrics',
|
||||||
{ id: 'maxMonthlySales', label: 'Max Monthly Sales Avg', type: 'number', group: 'Sales Metrics' },
|
operators: ['=', '>', '>=', '<', '<=', 'between']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'weeklySalesAvg',
|
||||||
|
label: 'Weekly Sales Avg',
|
||||||
|
type: 'number',
|
||||||
|
group: 'Sales Metrics',
|
||||||
|
operators: ['=', '>', '>=', '<', '<=', 'between']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'monthlySalesAvg',
|
||||||
|
label: 'Monthly Sales Avg',
|
||||||
|
type: 'number',
|
||||||
|
group: 'Sales Metrics',
|
||||||
|
operators: ['=', '>', '>=', '<', '<=', 'between']
|
||||||
|
},
|
||||||
|
|
||||||
// Financial Metrics Group
|
// Financial Metrics Group
|
||||||
{ id: 'minMargin', label: 'Min Margin %', type: 'number', group: 'Financial Metrics' },
|
{
|
||||||
{ id: 'maxMargin', label: 'Max Margin %', type: 'number', group: 'Financial Metrics' },
|
id: 'margin',
|
||||||
{ id: 'minGMROI', label: 'Min GMROI', type: 'number', group: 'Financial Metrics' },
|
label: 'Margin %',
|
||||||
{ id: 'maxGMROI', label: 'Max GMROI', type: 'number', group: 'Financial Metrics' },
|
type: 'number',
|
||||||
|
group: 'Financial Metrics',
|
||||||
|
operators: ['=', '>', '>=', '<', '<=', 'between']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gmroi',
|
||||||
|
label: 'GMROI',
|
||||||
|
type: 'number',
|
||||||
|
group: 'Financial Metrics',
|
||||||
|
operators: ['=', '>', '>=', '<', '<=', 'between']
|
||||||
|
},
|
||||||
|
|
||||||
// Lead Time & Stock Coverage Group
|
// Lead Time & Stock Coverage Group
|
||||||
{ id: 'minLeadTime', label: 'Min Lead Time (Days)', type: 'number', group: 'Lead Time & Coverage' },
|
{
|
||||||
{ id: 'maxLeadTime', label: 'Max Lead Time (Days)', type: 'number', group: 'Lead Time & Coverage' },
|
id: 'leadTime',
|
||||||
|
label: 'Lead Time (Days)',
|
||||||
|
type: 'number',
|
||||||
|
group: 'Lead Time & Coverage',
|
||||||
|
operators: ['=', '>', '>=', '<', '<=', 'between']
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'leadTimeStatus',
|
id: 'leadTimeStatus',
|
||||||
label: 'Lead Time Status',
|
label: 'Lead Time Status',
|
||||||
@@ -108,8 +178,13 @@ const FILTER_OPTIONS: FilterOption[] = [
|
|||||||
],
|
],
|
||||||
group: 'Lead Time & Coverage'
|
group: 'Lead Time & Coverage'
|
||||||
},
|
},
|
||||||
{ id: 'minStockCoverage', label: 'Min Stock Coverage Ratio', type: 'number', group: 'Lead Time & Coverage' },
|
{
|
||||||
{ id: 'maxStockCoverage', label: 'Max Stock Coverage Ratio', type: 'number', group: 'Lead Time & Coverage' },
|
id: 'stockCoverage',
|
||||||
|
label: 'Stock Coverage Ratio',
|
||||||
|
type: 'number',
|
||||||
|
group: 'Lead Time & Coverage',
|
||||||
|
operators: ['=', '>', '>=', '<', '<=', 'between']
|
||||||
|
},
|
||||||
|
|
||||||
// Classification Group
|
// Classification Group
|
||||||
{
|
{
|
||||||
@@ -139,9 +214,9 @@ interface ProductFiltersProps {
|
|||||||
categories: string[];
|
categories: string[];
|
||||||
vendors: string[];
|
vendors: string[];
|
||||||
brands: string[];
|
brands: string[];
|
||||||
onFilterChange: (filters: Record<string, FilterValue>) => void;
|
onFilterChange: (filters: Record<string, ActiveFilterValue>) => void;
|
||||||
onClearFilters: () => void;
|
onClearFilters: () => void;
|
||||||
activeFilters: Record<string, FilterValue>;
|
activeFilters: Record<string, ActiveFilterValue>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProductFilters({
|
export function ProductFilters({
|
||||||
@@ -154,7 +229,9 @@ export function ProductFilters({
|
|||||||
}: ProductFiltersProps) {
|
}: ProductFiltersProps) {
|
||||||
const [showCommand, setShowCommand] = React.useState(false);
|
const [showCommand, setShowCommand] = React.useState(false);
|
||||||
const [selectedFilter, setSelectedFilter] = React.useState<FilterOption | null>(null);
|
const [selectedFilter, setSelectedFilter] = React.useState<FilterOption | null>(null);
|
||||||
|
const [selectedOperator, setSelectedOperator] = React.useState<ComparisonOperator>('=');
|
||||||
const [inputValue, setInputValue] = React.useState("");
|
const [inputValue, setInputValue] = React.useState("");
|
||||||
|
const [inputValue2, setInputValue2] = React.useState(""); // For 'between' operator
|
||||||
const [searchValue, setSearchValue] = React.useState("");
|
const [searchValue, setSearchValue] = React.useState("");
|
||||||
|
|
||||||
// Handle keyboard shortcuts
|
// Handle keyboard shortcuts
|
||||||
@@ -222,17 +299,22 @@ export function ProductFilters({
|
|||||||
setInputValue("");
|
setInputValue("");
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleApplyFilter = (value: FilterValue) => {
|
const handleApplyFilter = (value: FilterValue | [number, number]) => {
|
||||||
if (!selectedFilter) return;
|
if (!selectedFilter) return;
|
||||||
|
|
||||||
const newFilters = {
|
const newFilters = {
|
||||||
...activeFilters,
|
...activeFilters,
|
||||||
[selectedFilter.id]: value
|
[selectedFilter.id]: {
|
||||||
|
value,
|
||||||
|
operator: selectedOperator
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
onFilterChange(newFilters);
|
onFilterChange(newFilters as Record<string, ActiveFilterValue>);
|
||||||
setSelectedFilter(null);
|
setSelectedFilter(null);
|
||||||
|
setSelectedOperator('=');
|
||||||
setInputValue("");
|
setInputValue("");
|
||||||
|
setInputValue2("");
|
||||||
setSearchValue("");
|
setSearchValue("");
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -277,6 +359,119 @@ export function ProductFilters({
|
|||||||
});
|
});
|
||||||
}, [activeFilters, filterOptions]);
|
}, [activeFilters, filterOptions]);
|
||||||
|
|
||||||
|
const renderOperatorSelect = () => (
|
||||||
|
<ToggleGroup
|
||||||
|
type="single"
|
||||||
|
value={selectedOperator}
|
||||||
|
onValueChange={(value: ComparisonOperator) => value && setSelectedOperator(value)}
|
||||||
|
className="flex-wrap"
|
||||||
|
>
|
||||||
|
<ToggleGroupItem value="=" aria-label="equals">=</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem value=">" aria-label="greater than">{'>'}</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem value=">=" aria-label="greater than or equal">≥</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem value="<" aria-label="less than">{'<'}</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem value="<=" aria-label="less than or equal">≤</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem value="between" aria-label="between">Between</ToggleGroupItem>
|
||||||
|
</ToggleGroup>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderNumberInput = () => (
|
||||||
|
<div className="flex flex-col gap-4 items-start">
|
||||||
|
{renderOperatorSelect()}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder={`Enter ${selectedFilter?.label.toLowerCase()}`}
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(e) => setInputValue(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
if (selectedOperator === 'between') {
|
||||||
|
if (inputValue2) {
|
||||||
|
const val1 = parseFloat(inputValue);
|
||||||
|
const val2 = parseFloat(inputValue2);
|
||||||
|
if (!isNaN(val1) && !isNaN(val2)) {
|
||||||
|
handleApplyFilter([val1, val2]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const val = parseFloat(inputValue);
|
||||||
|
if (!isNaN(val)) {
|
||||||
|
handleApplyFilter(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-[120px] [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||||
|
/>
|
||||||
|
{selectedOperator === 'between' && (
|
||||||
|
<>
|
||||||
|
<span>and</span>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder={`Enter maximum`}
|
||||||
|
value={inputValue2}
|
||||||
|
onChange={(e) => setInputValue2(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
const val1 = parseFloat(inputValue);
|
||||||
|
const val2 = parseFloat(inputValue2);
|
||||||
|
if (!isNaN(val1) && !isNaN(val2)) {
|
||||||
|
handleApplyFilter([val1, val2]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-[120px] [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
if (selectedOperator === 'between') {
|
||||||
|
const val1 = parseFloat(inputValue);
|
||||||
|
const val2 = parseFloat(inputValue2);
|
||||||
|
if (!isNaN(val1) && !isNaN(val2)) {
|
||||||
|
handleApplyFilter([val1, val2]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const val = parseFloat(inputValue);
|
||||||
|
if (!isNaN(val)) {
|
||||||
|
handleApplyFilter(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Apply
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const getFilterDisplayValue = (filter: ActiveFilter) => {
|
||||||
|
if (Array.isArray(filter.value)) {
|
||||||
|
return `between ${filter.value[0]} and ${filter.value[1]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filterValue = activeFilters[filter.id];
|
||||||
|
const operator = typeof filterValue === 'object' && 'operator' in filterValue
|
||||||
|
? filterValue.operator
|
||||||
|
: '=';
|
||||||
|
const value = typeof filterValue === 'object' && 'value' in filterValue
|
||||||
|
? filterValue.value
|
||||||
|
: filterValue;
|
||||||
|
|
||||||
|
const operatorDisplay = {
|
||||||
|
'=': '=',
|
||||||
|
'>': '>',
|
||||||
|
'>=': '≥',
|
||||||
|
'<': '<',
|
||||||
|
'<=': '≤',
|
||||||
|
'between': 'between'
|
||||||
|
}[operator];
|
||||||
|
|
||||||
|
return `${operatorDisplay} ${value}`;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
@@ -287,23 +482,15 @@ export function ProductFilters({
|
|||||||
size="sm"
|
size="sm"
|
||||||
className="h-8 border-dashed"
|
className="h-8 border-dashed"
|
||||||
>
|
>
|
||||||
<Plus
|
<Plus className={cn(
|
||||||
className={cn(
|
"mr-2 h-4 w-4 transition-transform duration-200",
|
||||||
"mr-2 h-4 w-4 transition-transform duration-200",
|
showCommand && "rotate-[135deg]"
|
||||||
showCommand && "rotate-[135deg]"
|
)} />
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{showCommand ? "Cancel" : "Add Filter"}
|
{showCommand ? "Cancel" : "Add Filter"}
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent
|
<PopoverContent className="p-0 w-[520px]" align="start">
|
||||||
className="p-0 w-[520px]"
|
<Command className="rounded-none border-0" shouldFilter={false}>
|
||||||
align="start"
|
|
||||||
>
|
|
||||||
<Command
|
|
||||||
className="rounded-none border-0"
|
|
||||||
shouldFilter={false}
|
|
||||||
>
|
|
||||||
{!selectedFilter ? (
|
{!selectedFilter ? (
|
||||||
<>
|
<>
|
||||||
<CommandInput
|
<CommandInput
|
||||||
@@ -351,6 +538,19 @@ export function ProductFilters({
|
|||||||
))}
|
))}
|
||||||
</CommandList>
|
</CommandList>
|
||||||
</>
|
</>
|
||||||
|
) : selectedFilter.type === 'number' ? (
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="mb-4">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setSelectedFilter(null)}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
>
|
||||||
|
← Back to filters
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{renderNumberInput()}
|
||||||
|
</div>
|
||||||
) : selectedFilter.type === 'select' ? (
|
) : selectedFilter.type === 'select' ? (
|
||||||
<>
|
<>
|
||||||
<CommandInput
|
<CommandInput
|
||||||
@@ -479,7 +679,7 @@ export function ProductFilters({
|
|||||||
className="flex items-center gap-1"
|
className="flex items-center gap-1"
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
{filter.label}: {filter.displayValue}
|
{filter.label}: {getFilterDisplayValue(filter)}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -488,6 +688,7 @@ export function ProductFilters({
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
const newFilters = { ...activeFilters };
|
const newFilters = { ...activeFilters };
|
||||||
delete newFilters[filter.id];
|
delete newFilters[filter.id];
|
||||||
|
delete newFilters[`${filter.id}_operator`];
|
||||||
onFilterChange(newFilters);
|
onFilterChange(newFilters);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
59
inventory/src/components/ui/toggle-group.tsx
Normal file
59
inventory/src/components/ui/toggle-group.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
|
||||||
|
import { type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { toggleVariants } from "@/components/ui/toggle"
|
||||||
|
|
||||||
|
const ToggleGroupContext = React.createContext<
|
||||||
|
VariantProps<typeof toggleVariants>
|
||||||
|
>({
|
||||||
|
size: "default",
|
||||||
|
variant: "default",
|
||||||
|
})
|
||||||
|
|
||||||
|
const ToggleGroup = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
|
||||||
|
VariantProps<typeof toggleVariants>
|
||||||
|
>(({ className, variant, size, children, ...props }, ref) => (
|
||||||
|
<ToggleGroupPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex items-center justify-center gap-1", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ToggleGroupContext.Provider value={{ variant, size }}>
|
||||||
|
{children}
|
||||||
|
</ToggleGroupContext.Provider>
|
||||||
|
</ToggleGroupPrimitive.Root>
|
||||||
|
))
|
||||||
|
|
||||||
|
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName
|
||||||
|
|
||||||
|
const ToggleGroupItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
|
||||||
|
VariantProps<typeof toggleVariants>
|
||||||
|
>(({ className, children, variant, size, ...props }, ref) => {
|
||||||
|
const context = React.useContext(ToggleGroupContext)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToggleGroupPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
toggleVariants({
|
||||||
|
variant: context.variant || variant,
|
||||||
|
size: context.size || size,
|
||||||
|
}),
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ToggleGroupPrimitive.Item>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName
|
||||||
|
|
||||||
|
export { ToggleGroup, ToggleGroupItem }
|
||||||
45
inventory/src/components/ui/toggle.tsx
Normal file
45
inventory/src/components/ui/toggle.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as TogglePrimitive from "@radix-ui/react-toggle"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const toggleVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-transparent",
|
||||||
|
outline:
|
||||||
|
"border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-2 min-w-9",
|
||||||
|
sm: "h-8 px-1.5 min-w-8",
|
||||||
|
lg: "h-10 px-2.5 min-w-10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const Toggle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TogglePrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &
|
||||||
|
VariantProps<typeof toggleVariants>
|
||||||
|
>(({ className, variant, size, ...props }, ref) => (
|
||||||
|
<TogglePrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(toggleVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
|
||||||
|
Toggle.displayName = TogglePrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Toggle, toggleVariants }
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect, useMemo } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import { useQuery, keepPreviousData } from '@tanstack/react-query';
|
import { useQuery, keepPreviousData } from '@tanstack/react-query';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { ProductFilters } from '@/components/products/ProductFilters';
|
import { ProductFilters, type ActiveFilterValue } from '@/components/products/ProductFilters';
|
||||||
import { ProductTable } from '@/components/products/ProductTable';
|
import { ProductTable } from '@/components/products/ProductTable';
|
||||||
import { ProductTableSkeleton } from '@/components/products/ProductTableSkeleton';
|
import { ProductTableSkeleton } from '@/components/products/ProductTableSkeleton';
|
||||||
import { ProductDetail } from '@/components/products/ProductDetail';
|
import { ProductDetail } from '@/components/products/ProductDetail';
|
||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
} from "@/components/ui/pagination"
|
} from "@/components/ui/pagination"
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
// Column definition type
|
// Column definition type
|
||||||
interface ColumnDef {
|
interface ColumnDef {
|
||||||
@@ -153,7 +154,7 @@ const VIEW_COLUMNS: Record<string, ColumnKey[]> = {
|
|||||||
|
|
||||||
export function Products() {
|
export function Products() {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const [filters, setFilters] = useState<Record<string, string | number | boolean>>({});
|
const [filters, setFilters] = useState<Record<string, ActiveFilterValue>>({});
|
||||||
const [sortColumn, setSortColumn] = useState<ColumnKey>('title');
|
const [sortColumn, setSortColumn] = useState<ColumnKey>('title');
|
||||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
@@ -161,6 +162,7 @@ export function Products() {
|
|||||||
const [pageSize] = useState(50);
|
const [pageSize] = useState(50);
|
||||||
const [showNonReplenishable, setShowNonReplenishable] = useState(false);
|
const [showNonReplenishable, setShowNonReplenishable] = useState(false);
|
||||||
const [selectedProductId, setSelectedProductId] = useState<number | null>(null);
|
const [selectedProductId, setSelectedProductId] = useState<number | null>(null);
|
||||||
|
const [, setIsLoading] = useState(false);
|
||||||
|
|
||||||
// Store visible columns and order for each view
|
// Store visible columns and order for each view
|
||||||
const [viewColumns, setViewColumns] = useState<Record<string, Set<ColumnKey>>>(() => {
|
const [viewColumns, setViewColumns] = useState<Record<string, Set<ColumnKey>>>(() => {
|
||||||
@@ -228,41 +230,65 @@ export function Products() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Function to fetch products data
|
// Function to fetch products data
|
||||||
const fetchProducts = async () => {
|
const transformFilters = (filters: Record<string, any>) => {
|
||||||
const params = new URLSearchParams();
|
const transformedFilters: Record<string, any> = {};
|
||||||
|
|
||||||
// Add pagination params
|
|
||||||
params.append('page', currentPage.toString());
|
|
||||||
params.append('limit', pageSize.toString());
|
|
||||||
|
|
||||||
// Add sorting params
|
|
||||||
if (sortColumn) {
|
|
||||||
params.append('sort', sortColumn);
|
|
||||||
params.append('order', sortDirection);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add view filter
|
|
||||||
if (activeView !== 'all') {
|
|
||||||
params.append('stockStatus', activeView === 'at-risk' ? 'At Risk' : activeView);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add showNonReplenishable param
|
|
||||||
if (showNonReplenishable) {
|
|
||||||
params.append('showNonReplenishable', 'true');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add other filters
|
|
||||||
Object.entries(filters).forEach(([key, value]) => {
|
Object.entries(filters).forEach(([key, value]) => {
|
||||||
if (value !== undefined && value !== '') {
|
if (typeof value === 'object' && 'operator' in value) {
|
||||||
params.append(key, value.toString());
|
transformedFilters[key] = value.value;
|
||||||
|
transformedFilters[`${key}_operator`] = value.operator;
|
||||||
|
} else {
|
||||||
|
transformedFilters[key] = value;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await fetch('/api/products?' + params.toString());
|
return transformedFilters;
|
||||||
if (!response.ok) {
|
};
|
||||||
throw new Error('Failed to fetch products');
|
|
||||||
|
const fetchProducts = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.append('page', currentPage.toString());
|
||||||
|
params.append('limit', pageSize.toString());
|
||||||
|
|
||||||
|
if (sortColumn) {
|
||||||
|
params.append('sort', sortColumn);
|
||||||
|
params.append('order', sortDirection);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeView && activeView !== 'all') {
|
||||||
|
params.append('stockStatus', activeView === 'at-risk' ? 'At Risk' : activeView);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform filters to match API expectations
|
||||||
|
const transformedFilters = transformFilters(filters);
|
||||||
|
Object.entries(transformedFilters).forEach(([key, value]) => {
|
||||||
|
if (value !== undefined && value !== null && value !== '') {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
params.append(key, JSON.stringify(value));
|
||||||
|
} else {
|
||||||
|
params.append(key, value.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!showNonReplenishable) {
|
||||||
|
params.append('showNonReplenishable', 'false');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`/api/products?${params.toString()}`);
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch products');
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching products:', error);
|
||||||
|
toast("Failed to fetch products. Please try again.");
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
return response.json();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Query for products data
|
// Query for products data
|
||||||
@@ -289,7 +315,7 @@ export function Products() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Handle filter changes
|
// Handle filter changes
|
||||||
const handleFilterChange = (newFilters: Record<string, string | number | boolean>) => {
|
const handleFilterChange = (newFilters: Record<string, ActiveFilterValue>) => {
|
||||||
setFilters(newFilters);
|
setFilters(newFilters);
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user