Enhance product page filtering

This commit is contained in:
2025-01-13 12:11:51 -05:00
parent dd882490c8
commit fffc0e759c
4 changed files with 556 additions and 290 deletions

View File

@@ -1,129 +1,282 @@
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import * as React from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { X, Plus } from "lucide-react";
import { DialogTitle, DialogDescription } from "@/components/ui/dialog";
interface ProductFilters {
search: string;
category: string;
vendor: string;
stockStatus: string;
minPrice: string;
maxPrice: string;
type FilterValue = string | number | boolean;
interface FilterOption {
id: string;
label: string;
type: 'select' | 'number' | 'boolean' | 'text';
options?: { label: string; value: string }[];
group: string;
}
interface ActiveFilter {
id: string;
label: string;
value: FilterValue;
displayValue: string;
}
const FILTER_OPTIONS: FilterOption[] = [
// Basic Info Group
{ id: 'search', label: 'Search', type: 'text', group: 'Basic Info' },
{ id: 'sku', label: 'SKU', type: 'text', group: 'Basic Info' },
{ id: 'vendor', label: 'Vendor', type: 'select', group: 'Basic Info' },
{ id: 'brand', label: 'Brand', type: 'select', group: 'Basic Info' },
{ id: 'category', label: 'Category', type: 'select', group: 'Basic Info' },
// Inventory Group
{
id: 'stockStatus',
label: 'Stock Status',
type: 'select',
options: [
{ label: 'Critical', value: 'critical' },
{ label: 'Reorder', value: 'reorder' },
{ label: 'Healthy', value: 'healthy' },
{ label: 'Overstocked', value: 'overstocked' },
{ label: 'New', value: 'new' },
],
group: 'Inventory'
},
{ id: 'minStock', label: 'Min Stock', type: 'number', group: 'Inventory' },
{ id: 'maxStock', label: 'Max Stock', type: 'number', group: 'Inventory' },
// Pricing Group
{ id: 'minPrice', label: 'Min Price', type: 'number', group: 'Pricing' },
{ id: 'maxPrice', label: 'Max Price', type: 'number', group: 'Pricing' },
// Sales Metrics Group
{ id: 'minSalesAvg', label: 'Min Daily Sales Avg', type: 'number', group: 'Sales Metrics' },
{ id: 'maxSalesAvg', label: 'Max Daily Sales Avg', type: 'number', group: 'Sales Metrics' },
// Financial Metrics Group
{ id: 'minMargin', label: 'Min Margin %', type: 'number', group: 'Financial Metrics' },
{ id: 'maxMargin', label: 'Max Margin %', type: 'number', group: 'Financial Metrics' },
{ id: 'minGMROI', label: 'Min GMROI', type: 'number', group: 'Financial Metrics' },
{ id: 'maxGMROI', label: 'Max GMROI', type: 'number', group: 'Financial Metrics' },
// Classification Group
{
id: 'abcClass',
label: 'ABC Class',
type: 'select',
options: [
{ label: 'A', value: 'A' },
{ label: 'B', value: 'B' },
{ label: 'C', value: 'C' },
],
group: 'Classification'
},
];
interface ProductFiltersProps {
filters: ProductFilters;
categories: string[];
vendors: string[];
onFilterChange: (filters: Partial<ProductFilters>) => void;
onFilterChange: (filters: Record<string, FilterValue>) => void;
onClearFilters: () => void;
activeFilters: Record<string, FilterValue>;
}
export function ProductFilters({
filters,
categories,
vendors,
onFilterChange,
onClearFilters
export function ProductFilters({
categories,
vendors,
onFilterChange,
onClearFilters,
activeFilters,
}: ProductFiltersProps) {
const activeFilterCount = Object.values(filters).filter(Boolean).length;
const [open, setOpen] = React.useState(false);
const [selectedFilter, setSelectedFilter] = React.useState<FilterOption | null>(null);
const [filterValue, setFilterValue] = React.useState<string>("");
// Update filter options with dynamic data
const filterOptions = React.useMemo(() => {
return FILTER_OPTIONS.map(option => {
if (option.id === 'category') {
return {
...option,
options: categories.map(cat => ({ label: cat, value: cat }))
};
}
if (option.id === 'vendor') {
return {
...option,
options: vendors.map(vendor => ({ label: vendor, value: vendor }))
};
}
return option;
});
}, [categories, vendors]);
const activeFiltersList = React.useMemo(() => {
if (!activeFilters) return [];
return Object.entries(activeFilters).map(([id, value]): ActiveFilter => {
const option = filterOptions.find(opt => opt.id === id);
let displayValue = String(value);
if (option?.type === 'select' && option.options) {
const optionLabel = option.options.find(opt => opt.value === value)?.label;
if (optionLabel) displayValue = optionLabel;
}
return {
id,
label: option?.label || id,
value,
displayValue
};
});
}, [activeFilters, filterOptions]);
const handleSelectFilter = (filter: FilterOption) => {
setSelectedFilter(filter);
if (filter.type === 'select') {
setOpen(false);
// Open a new command dialog for selecting the value
// This will be handled in a separate component
} else {
// For other types, we'll show an input field
setFilterValue("");
}
};
const handleRemoveFilter = (filterId: string) => {
const newFilters = { ...activeFilters };
delete newFilters[filterId];
onFilterChange(newFilters);
};
const handleApplyFilter = (value: FilterValue) => {
if (!selectedFilter) return;
const newFilters = {
...activeFilters,
[selectedFilter.id]: value
};
onFilterChange(newFilters);
setSelectedFilter(null);
setFilterValue("");
setOpen(false);
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium">Filters</h3>
{activeFilterCount > 0 && (
<Button
variant="ghost"
size="sm"
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="h-8 border-dashed"
onClick={() => setOpen(true)}
>
<Plus className="mr-2 h-4 w-4" />
Add Filter
</Button>
{activeFiltersList.length > 0 && (
<Button
variant="ghost"
size="sm"
className="h-8"
onClick={onClearFilters}
className="h-8 px-2 lg:px-3"
>
Clear filters
<Badge variant="secondary" className="ml-2">
{activeFilterCount}
</Badge>
Clear Filters
</Button>
)}
</div>
<div className="grid gap-4">
<div>
<Input
placeholder="Search products..."
value={filters.search}
onChange={(e) => onFilterChange({ search: e.target.value })}
className="h-8 w-full"
/>
{activeFiltersList.length > 0 && (
<div className="flex flex-wrap gap-2">
{activeFiltersList.map((filter) => (
<Badge
key={filter.id}
variant="secondary"
className="flex items-center gap-1"
>
<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 className="grid grid-cols-2 gap-4">
<Select
value={filters.category}
onValueChange={(value) => onFilterChange({ category: value })}
>
<SelectTrigger className="h-8 w-full">
<SelectValue placeholder="Category" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Categories</SelectItem>
{categories.map((category) => (
<SelectItem key={category} value={category}>
{category}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={filters.vendor}
onValueChange={(value) => onFilterChange({ vendor: value })}
>
<SelectTrigger className="h-8 w-full">
<SelectValue placeholder="Vendor" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Vendors</SelectItem>
{vendors.map((vendor) => (
<SelectItem key={vendor} value={vendor}>
{vendor}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<Select
value={filters.stockStatus}
onValueChange={(value) => onFilterChange({ stockStatus: value })}
>
<SelectTrigger className="h-8 w-full">
<SelectValue placeholder="Stock Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Stock</SelectItem>
<SelectItem value="in_stock">In Stock</SelectItem>
<SelectItem value="low_stock">Low Stock</SelectItem>
<SelectItem value="out_of_stock">Out of Stock</SelectItem>
</SelectContent>
</Select>
<div className="flex items-center space-x-2">
<Input
type="number"
placeholder="Min $"
value={filters.minPrice}
onChange={(e) => onFilterChange({ minPrice: e.target.value })}
className="h-8 w-full"
/>
<span>-</span>
<Input
type="number"
placeholder="Max $"
value={filters.maxPrice}
onChange={(e) => onFilterChange({ maxPrice: e.target.value })}
className="h-8 w-full"
/>
</div>
</div>
</div>
)}
<CommandDialog open={open} onOpenChange={setOpen}>
<Command className="rounded-lg border shadow-md">
<DialogTitle className="sr-only">Search Filters</DialogTitle>
<DialogDescription className="sr-only">
Search and select filters to apply to the product list
</DialogDescription>
<CommandInput placeholder="Search filters..." />
<CommandList>
<CommandEmpty>No filters found.</CommandEmpty>
{Object.entries(
filterOptions.reduce<Record<string, FilterOption[]>>((acc, filter) => {
if (!acc[filter.group]) acc[filter.group] = [];
acc[filter.group].push(filter);
return acc;
}, {})
).map(([group, filters]) => (
<CommandGroup key={group} heading={group}>
{filters.map((filter) => (
<CommandItem
key={filter.id}
onSelect={() => handleSelectFilter(filter)}
>
{filter.label}
</CommandItem>
))}
</CommandGroup>
))}
</CommandList>
</Command>
</CommandDialog>
{selectedFilter?.type === 'select' && (
<CommandDialog open={!!selectedFilter} onOpenChange={() => setSelectedFilter(null)}>
<Command className="rounded-lg border shadow-md">
<DialogTitle className="sr-only">Select {selectedFilter.label}</DialogTitle>
<DialogDescription className="sr-only">
Choose a value for the {selectedFilter.label.toLowerCase()} filter
</DialogDescription>
<CommandInput placeholder={`Select ${selectedFilter.label.toLowerCase()}...`} />
<CommandList>
<CommandEmpty>No options found.</CommandEmpty>
<CommandGroup>
{selectedFilter.options?.map((option) => (
<CommandItem
key={option.value}
onSelect={() => handleApplyFilter(option.value)}
>
{option.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</CommandDialog>
)}
</div>
);
}

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

View File

@@ -164,14 +164,7 @@ const DEFAULT_VISIBLE_COLUMNS: (keyof Product)[] = [
export function Products() {
const queryClient = useQueryClient();
const tableRef = useRef<HTMLDivElement>(null);
const [filters, setFilters] = useState<ProductFiltersState>({
search: '',
category: 'all',
vendor: 'all',
stockStatus: 'all',
minPrice: '',
maxPrice: '',
});
const [filters, setFilters] = useState<Record<string, string | number | boolean>>({});
const [sortColumn, setSortColumn] = useState<keyof Product>('title');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
const [page, setPage] = useState(1);
@@ -215,49 +208,12 @@ export function Products() {
}
const result = await response.json();
return {
...result,
products: result.products.map((product: any) => ({
...product,
price: parseFloat(product.price) || 0,
regular_price: parseFloat(product.regular_price) || 0,
cost_price: parseFloat(product.cost_price) || 0,
stock_quantity: parseInt(product.stock_quantity) || 0
}))
};
return result;
};
const { data, isLoading, isFetching } = useQuery({
queryKey: ['products', filters, sortColumn, sortDirection, page],
queryFn: async () => {
const searchParams = new URLSearchParams({
page: page.toString(),
limit: '100',
sortColumn: sortColumn.toString(),
sortDirection,
...filters,
});
const response = await fetch(`${config.apiUrl}/products?${searchParams}`);
if (!response.ok) {
throw new Error('Network response was not ok');
}
const result = await response.json();
return {
...result,
products: result.products.map((product: any) => ({
...product,
price: parseFloat(product.price) || 0,
regular_price: parseFloat(product.regular_price) || 0,
cost_price: parseFloat(product.cost_price) || 0,
stock_quantity: parseInt(product.stock_quantity) || 0,
sku: product.SKU || product.sku || '',
image: product.image || null,
categories: Array.isArray(product.categories) ? product.categories : []
}))
};
},
queryFn: () => fetchProducts(page),
placeholderData: keepPreviousData,
staleTime: 30000,
});
@@ -312,31 +268,14 @@ export function Products() {
}
};
// Debounce the filter changes with a shorter delay
const debouncedFilterChange = useCallback(
debounce((newFilters: Partial<ProductFiltersState>) => {
setFilters(prev => ({ ...prev, ...newFilters }));
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);
// Handle filter changes
const handleFilterChange = (newFilters: Record<string, string | number | boolean>) => {
setFilters(newFilters);
setPage(1);
};
const handleClearFilters = () => {
setFilters({
search: '',
category: 'all',
vendor: 'all',
stockStatus: 'all',
minPrice: '',
maxPrice: '',
});
setFilters({});
setPage(1);
};
@@ -475,6 +414,7 @@ export function Products() {
vendors={data?.filters.vendors ?? []}
onFilterChange={handleFilterChange}
onClearFilters={handleClearFilters}
activeFilters={filters}
/>
<div ref={tableRef}>