Add operators for numerical filters

This commit is contained in:
2025-01-15 15:36:08 -05:00
parent 9e21d593f1
commit 82da563ee1
7 changed files with 530 additions and 238 deletions

View File

@@ -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' });
}
});

View File

@@ -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",

View File

@@ -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",

View File

@@ -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<string, FilterValue>) => void;
onFilterChange: (filters: Record<string, ActiveFilterValue>) => void;
onClearFilters: () => void;
activeFilters: Record<string, FilterValue>;
activeFilters: Record<string, ActiveFilterValue>;
}
export function ProductFilters({
@@ -154,7 +229,9 @@ export function ProductFilters({
}: ProductFiltersProps) {
const [showCommand, setShowCommand] = React.useState(false);
const [selectedFilter, setSelectedFilter] = React.useState<FilterOption | null>(null);
const [selectedOperator, setSelectedOperator] = React.useState<ComparisonOperator>('=');
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<string, ActiveFilterValue>);
setSelectedFilter(null);
setSelectedOperator('=');
setInputValue("");
setInputValue2("");
setSearchValue("");
};
@@ -277,6 +359,119 @@ export function ProductFilters({
});
}, [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 (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
@@ -287,23 +482,15 @@ export function ProductFilters({
size="sm"
className="h-8 border-dashed"
>
<Plus
className={cn(
"mr-2 h-4 w-4 transition-transform duration-200",
showCommand && "rotate-[135deg]"
)}
/>
<Plus className={cn(
"mr-2 h-4 w-4 transition-transform duration-200",
showCommand && "rotate-[135deg]"
)} />
{showCommand ? "Cancel" : "Add Filter"}
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0 w-[520px]"
align="start"
>
<Command
className="rounded-none border-0"
shouldFilter={false}
>
<PopoverContent className="p-0 w-[520px]" align="start">
<Command className="rounded-none border-0" shouldFilter={false}>
{!selectedFilter ? (
<>
<CommandInput
@@ -351,6 +538,19 @@ export function ProductFilters({
))}
</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' ? (
<>
<CommandInput
@@ -479,7 +679,7 @@ export function ProductFilters({
className="flex items-center gap-1"
>
<span>
{filter.label}: {filter.displayValue}
{filter.label}: {getFilterDisplayValue(filter)}
</span>
<Button
variant="ghost"
@@ -488,6 +688,7 @@ export function ProductFilters({
onClick={() => {
const newFilters = { ...activeFilters };
delete newFilters[filter.id];
delete newFilters[`${filter.id}_operator`];
onFilterChange(newFilters);
}}
>

View 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 }

View 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 }

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, useMemo } from 'react';
import { useQuery, keepPreviousData } from '@tanstack/react-query';
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 { ProductTableSkeleton } from '@/components/products/ProductTableSkeleton';
import { ProductDetail } from '@/components/products/ProductDetail';
@@ -30,6 +30,7 @@ import {
} from "@/components/ui/pagination"
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { toast } from "sonner";
// Column definition type
interface ColumnDef {
@@ -153,7 +154,7 @@ const VIEW_COLUMNS: Record<string, ColumnKey[]> = {
export function Products() {
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 [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
const [currentPage, setCurrentPage] = useState(1);
@@ -161,6 +162,7 @@ export function Products() {
const [pageSize] = useState(50);
const [showNonReplenishable, setShowNonReplenishable] = useState(false);
const [selectedProductId, setSelectedProductId] = useState<number | null>(null);
const [, setIsLoading] = useState(false);
// Store visible columns and order for each view
const [viewColumns, setViewColumns] = useState<Record<string, Set<ColumnKey>>>(() => {
@@ -228,41 +230,65 @@ export function Products() {
};
// Function to fetch products data
const fetchProducts = async () => {
const params = new URLSearchParams();
const transformFilters = (filters: Record<string, any>) => {
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]) => {
if (value !== undefined && value !== '') {
params.append(key, value.toString());
if (typeof value === 'object' && 'operator' in value) {
transformedFilters[key] = value.value;
transformedFilters[`${key}_operator`] = value.operator;
} else {
transformedFilters[key] = value;
}
});
const response = await fetch('/api/products?' + params.toString());
if (!response.ok) {
throw new Error('Failed to fetch products');
return transformedFilters;
};
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
@@ -289,7 +315,7 @@ export function Products() {
};
// Handle filter changes
const handleFilterChange = (newFilters: Record<string, string | number | boolean>) => {
const handleFilterChange = (newFilters: Record<string, ActiveFilterValue>) => {
setFilters(newFilters);
setCurrentPage(1);
};