From 82da563ee12c604948f7629f2711d930cfcc4ef5 Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 15 Jan 2025 15:36:08 -0500 Subject: [PATCH] Add operators for numerical filters --- inventory-server/src/routes/products.js | 215 ++++--------- inventory/package-lock.json | 56 ++++ inventory/package.json | 2 + .../components/products/ProductFilters.tsx | 301 +++++++++++++++--- inventory/src/components/ui/toggle-group.tsx | 59 ++++ inventory/src/components/ui/toggle.tsx | 45 +++ inventory/src/pages/Products.tsx | 90 ++++-- 7 files changed, 530 insertions(+), 238 deletions(-) create mode 100644 inventory/src/components/ui/toggle-group.tsx create mode 100644 inventory/src/components/ui/toggle.tsx diff --git a/inventory-server/src/routes/products.js b/inventory-server/src/routes/products.js index 56768a8..8f7644a 100755 --- a/inventory-server/src/routes/products.js +++ b/inventory-server/src/routes/products.js @@ -16,7 +16,6 @@ router.get('/', async (req, res) => { const sortColumn = req.query.sort || 'title'; const sortDirection = req.query.order === 'desc' ? 'DESC' : 'ASC'; - // Build the WHERE clause const conditions = ['p.visible = true']; const params = []; @@ -25,185 +24,89 @@ router.get('/', async (req, res) => { conditions.push('p.replenishable = true'); } - // Handle text search filters + // Handle search filter if (req.query.search) { - conditions.push('(p.title LIKE ? OR p.SKU LIKE ?)'); - params.push(`%${req.query.search}%`, `%${req.query.search}%`); + conditions.push('(p.title LIKE ? OR p.SKU LIKE ? OR p.barcode LIKE ?)'); + const searchTerm = `%${req.query.search}%`; + params.push(searchTerm, searchTerm, searchTerm); } - if (req.query.sku) { - conditions.push('p.SKU LIKE ?'); - params.push(`%${req.query.sku}%`); - } + // Handle numeric filters with operators + const numericFields = { + 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 - if (req.query.category && req.query.category !== 'all') { - 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') { + if (req.query.vendor) { conditions.push('p.vendor = ?'); params.push(req.query.vendor); } - if (req.query.brand && req.query.brand !== 'all') { + if (req.query.brand) { conditions.push('p.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) { 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.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) { conditions.push('pm.lead_time_status = ?'); params.push(req.query.leadTimeStatus); } - if (req.query.minStockCoverage) { - conditions.push('(pm.days_of_inventory / pt.target_days) >= ?'); - params.push(parseFloat(req.query.minStockCoverage)); + if (req.query.replenishable !== undefined) { + conditions.push('p.replenishable = ?'); + params.push(req.query.replenishable === 'true' ? 1 : 0); } - if (req.query.maxStockCoverage) { - conditions.push('(pm.days_of_inventory / pt.target_days) <= ?'); - params.push(parseFloat(req.query.maxStockCoverage)); - } - - // Handle stock status filter - if (req.query.stockStatus && req.query.stockStatus !== 'all') { - conditions.push('pm.stock_status = ?'); - params.push(req.query.stockStatus); + if (req.query.managingStock !== undefined) { + conditions.push('p.managing_stock = ?'); + params.push(req.query.managingStock === 'true' ? 1 : 0); } // Combine all conditions with AND @@ -359,7 +262,7 @@ router.get('/', async (req, res) => { }); } catch (error) { console.error('Error fetching products:', error); - res.status(500).json({ error: 'Internal server error' }); + res.status(500).json({ error: 'Failed to fetch products' }); } }); diff --git a/inventory/package-lock.json b/inventory/package-lock.json index 546df48..3ebc03e 100644 --- a/inventory/package-lock.json +++ b/inventory/package-lock.json @@ -24,6 +24,8 @@ "@radix-ui/react-slot": "^1.1.1", "@radix-ui/react-switch": "^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", "@shadcn/ui": "^0.0.4", "@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": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.1.6.tgz", diff --git a/inventory/package.json b/inventory/package.json index 51a702e..7d65c8b 100644 --- a/inventory/package.json +++ b/inventory/package.json @@ -26,6 +26,8 @@ "@radix-ui/react-slot": "^1.1.1", "@radix-ui/react-switch": "^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", "@shadcn/ui": "^0.0.4", "@tanstack/react-query": "^5.63.0", diff --git a/inventory/src/components/products/ProductFilters.tsx b/inventory/src/components/products/ProductFilters.tsx index 6bd28ac..56a80a3 100644 --- a/inventory/src/components/products/ProductFilters.tsx +++ b/inventory/src/components/products/ProductFilters.tsx @@ -17,8 +17,28 @@ import { PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; +import { Input } from "@/components/ui/input"; +import { + ToggleGroup, + ToggleGroupItem, +} from "@/components/ui/toggle-group"; 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 { id: string; @@ -26,13 +46,7 @@ interface FilterOption { type: 'select' | 'number' | 'boolean' | 'text'; options?: { label: string; value: string }[]; group: string; -} - -interface ActiveFilter { - id: string; - label: string; - value: FilterValue; - displayValue: string; + operators?: ComparisonOperator[]; } const FILTER_OPTIONS: FilterOption[] = [ @@ -58,9 +72,20 @@ const FILTER_OPTIONS: FilterOption[] = [ ], group: 'Inventory' }, - { id: 'minStock', label: 'Min Stock', type: 'number', group: 'Inventory' }, - { id: 'maxStock', label: 'Max Stock', type: 'number', group: 'Inventory' }, - { id: 'daysOfStock', label: 'Days of Stock', type: 'number', group: 'Inventory' }, + { + id: 'stock', + label: 'Stock Quantity', + type: 'number', + group: 'Inventory', + operators: ['=', '>', '>=', '<', '<=', 'between'] + }, + { + id: 'daysOfStock', + label: 'Days of Stock', + type: 'number', + group: 'Inventory', + operators: ['=', '>', '>=', '<', '<=', 'between'] + }, { id: 'replenishable', label: 'Replenishable', @@ -73,30 +98,75 @@ const FILTER_OPTIONS: FilterOption[] = [ }, // Pricing Group - { id: 'minPrice', label: 'Min Price', type: 'number', group: 'Pricing' }, - { id: 'maxPrice', label: 'Max Price', type: 'number', group: 'Pricing' }, - { id: 'minCostPrice', label: 'Min Cost Price', type: 'number', group: 'Pricing' }, - { id: 'maxCostPrice', label: 'Max Cost Price', type: 'number', group: 'Pricing' }, - { id: 'minLandingCost', label: 'Min Landing Cost', type: 'number', group: 'Pricing' }, - { id: 'maxLandingCost', label: 'Max Landing Cost', type: 'number', group: 'Pricing' }, + { + id: 'price', + label: 'Price', + 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 - { 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: 'minWeeklySales', label: 'Min Weekly Sales Avg', type: 'number', group: 'Sales Metrics' }, - { id: 'maxWeeklySales', label: 'Max Weekly Sales Avg', type: 'number', group: 'Sales Metrics' }, - { id: 'minMonthlySales', label: 'Min Monthly Sales Avg', type: 'number', group: 'Sales Metrics' }, - { id: 'maxMonthlySales', label: 'Max Monthly Sales Avg', type: 'number', group: 'Sales Metrics' }, + { + id: 'dailySalesAvg', + label: 'Daily 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 - { 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' }, + { + id: 'margin', + label: 'Margin %', + type: 'number', + group: 'Financial Metrics', + operators: ['=', '>', '>=', '<', '<=', 'between'] + }, + { + id: 'gmroi', + label: 'GMROI', + type: 'number', + group: 'Financial Metrics', + operators: ['=', '>', '>=', '<', '<=', 'between'] + }, // 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', label: 'Lead Time Status', @@ -108,8 +178,13 @@ const FILTER_OPTIONS: FilterOption[] = [ ], 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 { @@ -139,9 +214,9 @@ interface ProductFiltersProps { categories: string[]; vendors: string[]; brands: string[]; - onFilterChange: (filters: Record) => void; + onFilterChange: (filters: Record) => void; onClearFilters: () => void; - activeFilters: Record; + activeFilters: Record; } export function ProductFilters({ @@ -154,7 +229,9 @@ export function ProductFilters({ }: ProductFiltersProps) { const [showCommand, setShowCommand] = React.useState(false); const [selectedFilter, setSelectedFilter] = React.useState(null); + const [selectedOperator, setSelectedOperator] = React.useState('='); const [inputValue, setInputValue] = React.useState(""); + const [inputValue2, setInputValue2] = React.useState(""); // For 'between' operator const [searchValue, setSearchValue] = React.useState(""); // Handle keyboard shortcuts @@ -222,17 +299,22 @@ export function ProductFilters({ setInputValue(""); }, []); - const handleApplyFilter = (value: FilterValue) => { + const handleApplyFilter = (value: FilterValue | [number, number]) => { if (!selectedFilter) return; const newFilters = { ...activeFilters, - [selectedFilter.id]: value + [selectedFilter.id]: { + value, + operator: selectedOperator + } }; - onFilterChange(newFilters); + onFilterChange(newFilters as Record); setSelectedFilter(null); + setSelectedOperator('='); setInputValue(""); + setInputValue2(""); setSearchValue(""); }; @@ -277,6 +359,119 @@ export function ProductFilters({ }); }, [activeFilters, filterOptions]); + const renderOperatorSelect = () => ( + value && setSelectedOperator(value)} + className="flex-wrap" + > + = + {'>'} + + {'<'} + + Between + + ); + + const renderNumberInput = () => ( +
+ {renderOperatorSelect()} +
+ 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' && ( + <> + and + 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" + /> + + )} + +
+
+ ); + + 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 (
@@ -287,23 +482,15 @@ export function ProductFilters({ size="sm" className="h-8 border-dashed" > - + {showCommand ? "Cancel" : "Add Filter"} - - + + {!selectedFilter ? ( <> + ) : selectedFilter.type === 'number' ? ( +
+
+ +
+ {renderNumberInput()} +
) : selectedFilter.type === 'select' ? ( <> - {filter.label}: {filter.displayValue} + {filter.label}: {getFilterDisplayValue(filter)}