Add/fix filters and styling for product page, add drag and drop

This commit is contained in:
2025-01-13 13:20:38 -05:00
parent fffc0e759c
commit 19d068191c
6 changed files with 680 additions and 198 deletions

View File

@@ -70,6 +70,23 @@ router.get('/', async (req, res) => {
params.push(parseFloat(req.query.maxStock)); 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) { if (req.query.minPrice) {
conditions.push('p.price >= ?'); conditions.push('p.price >= ?');
params.push(parseFloat(req.query.minPrice)); params.push(parseFloat(req.query.minPrice));
@@ -80,6 +97,27 @@ router.get('/', async (req, res) => {
params.push(parseFloat(req.query.maxPrice)); 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) { if (req.query.minSalesAvg) {
conditions.push('pm.daily_sales_avg >= ?'); conditions.push('pm.daily_sales_avg >= ?');
params.push(parseFloat(req.query.minSalesAvg)); params.push(parseFloat(req.query.minSalesAvg));
@@ -90,6 +128,27 @@ router.get('/', async (req, res) => {
params.push(parseFloat(req.query.maxSalesAvg)); 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) { if (req.query.minMargin) {
conditions.push('pm.avg_margin_percent >= ?'); conditions.push('pm.avg_margin_percent >= ?');
params.push(parseFloat(req.query.minMargin)); params.push(parseFloat(req.query.minMargin));
@@ -110,6 +169,32 @@ router.get('/', async (req, res) => {
params.push(parseFloat(req.query.maxGMROI)); 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.maxStockCoverage) {
conditions.push('(pm.days_of_inventory / pt.target_days) <= ?');
params.push(parseFloat(req.query.maxStockCoverage));
}
// Handle status filters // Handle status filters
if (req.query.stockStatus && req.query.stockStatus !== 'all') { if (req.query.stockStatus && req.query.stockStatus !== 'all') {
conditions.push('pm.stock_status = ?'); conditions.push('pm.stock_status = ?');
@@ -208,10 +293,10 @@ router.get('/', async (req, res) => {
res.json({ res.json({
products, products,
pagination: { pagination: {
page,
limit,
total, total,
totalPages: Math.ceil(total / limit) currentPage: page,
pages: Math.ceil(total / limit),
limit
}, },
filters: { filters: {
categories: categories.map(category => category.name), categories: categories.map(category => category.name),

View File

@@ -8,6 +8,9 @@
"name": "inventory", "name": "inventory",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@radix-ui/react-alert-dialog": "^1.1.4", "@radix-ui/react-alert-dialog": "^1.1.4",
"@radix-ui/react-avatar": "^1.1.2", "@radix-ui/react-avatar": "^1.1.2",
"@radix-ui/react-collapsible": "^1.1.2", "@radix-ui/react-collapsible": "^1.1.2",
@@ -386,6 +389,59 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.24.2", "version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz",

View File

@@ -10,6 +10,9 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@radix-ui/react-alert-dialog": "^1.1.4", "@radix-ui/react-alert-dialog": "^1.1.4",
"@radix-ui/react-avatar": "^1.1.2", "@radix-ui/react-avatar": "^1.1.2",
"@radix-ui/react-collapsible": "^1.1.2", "@radix-ui/react-collapsible": "^1.1.2",

View File

@@ -3,15 +3,16 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Command, Command,
CommandDialog,
CommandEmpty, CommandEmpty,
CommandGroup, CommandGroup,
CommandInput, CommandInput,
CommandItem, CommandItem,
CommandList, CommandList,
CommandSeparator,
} from "@/components/ui/command"; } from "@/components/ui/command";
import { Input } from "@/components/ui/input";
import { X, Plus } from "lucide-react"; import { X, Plus } from "lucide-react";
import { DialogTitle, DialogDescription } from "@/components/ui/dialog"; import { cn } from "@/lib/utils";
type FilterValue = string | number | boolean; type FilterValue = string | number | boolean;
@@ -44,24 +45,43 @@ const FILTER_OPTIONS: FilterOption[] = [
label: 'Stock Status', label: 'Stock Status',
type: 'select', type: 'select',
options: [ options: [
{ label: 'Critical', value: 'critical' }, { label: 'Critical', value: 'Critical' },
{ label: 'Reorder', value: 'reorder' }, { label: 'Reorder', value: 'Reorder' },
{ label: 'Healthy', value: 'healthy' }, { label: 'Healthy', value: 'Healthy' },
{ label: 'Overstocked', value: 'overstocked' }, { label: 'Overstocked', value: 'Overstocked' },
{ label: 'New', value: 'new' }, { label: 'New', value: 'New' }
], ],
group: 'Inventory' group: 'Inventory'
}, },
{ id: 'minStock', label: 'Min Stock', type: 'number', group: 'Inventory' }, { id: 'minStock', label: 'Min Stock', type: 'number', group: 'Inventory' },
{ id: 'maxStock', label: 'Max 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: 'replenishable',
label: 'Replenishable',
type: 'select',
options: [
{ label: 'Yes', value: 'true' },
{ label: 'No', value: 'false' }
],
group: 'Inventory'
},
// Pricing Group // Pricing Group
{ id: 'minPrice', label: 'Min Price', type: 'number', group: 'Pricing' }, { id: 'minPrice', label: 'Min Price', type: 'number', group: 'Pricing' },
{ id: 'maxPrice', label: 'Max 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' },
// Sales Metrics Group // Sales Metrics Group
{ id: 'minSalesAvg', label: 'Min Daily Sales Avg', type: 'number', group: 'Sales Metrics' }, { 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: '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' },
// Financial Metrics Group // Financial Metrics Group
{ id: 'minMargin', label: 'Min Margin %', type: 'number', group: 'Financial Metrics' }, { id: 'minMargin', label: 'Min Margin %', type: 'number', group: 'Financial Metrics' },
@@ -69,6 +89,23 @@ const FILTER_OPTIONS: FilterOption[] = [
{ id: 'minGMROI', label: 'Min GMROI', 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: 'maxGMROI', label: 'Max GMROI', type: 'number', group: 'Financial Metrics' },
// 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: 'leadTimeStatus',
label: 'Lead Time Status',
type: 'select',
options: [
{ label: 'On Target', value: 'on_target' },
{ label: 'Warning', value: 'warning' },
{ label: 'Critical', value: 'critical' }
],
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' },
// Classification Group // Classification Group
{ {
id: 'abcClass', id: 'abcClass',
@@ -77,10 +114,20 @@ const FILTER_OPTIONS: FilterOption[] = [
options: [ options: [
{ label: 'A', value: 'A' }, { label: 'A', value: 'A' },
{ label: 'B', value: 'B' }, { label: 'B', value: 'B' },
{ label: 'C', value: 'C' }, { label: 'C', value: 'C' }
], ],
group: 'Classification' group: 'Classification'
}, },
{
id: 'managingStock',
label: 'Managing Stock',
type: 'select',
options: [
{ label: 'Yes', value: 'true' },
{ label: 'No', value: 'false' }
],
group: 'Classification'
}
]; ];
interface ProductFiltersProps { interface ProductFiltersProps {
@@ -98,9 +145,30 @@ export function ProductFilters({
onClearFilters, onClearFilters,
activeFilters, activeFilters,
}: ProductFiltersProps) { }: ProductFiltersProps) {
const [open, setOpen] = 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 [filterValue, setFilterValue] = React.useState<string>(""); const [inputValue, setInputValue] = React.useState("");
const [searchValue, setSearchValue] = React.useState("");
// Handle escape key
React.useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
if (selectedFilter) {
setSelectedFilter(null);
setInputValue("");
} else {
setShowCommand(false);
setSearchValue("");
}
}
};
if (showCommand) {
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}
}, [showCommand, selectedFilter]);
// Update filter options with dynamic data // Update filter options with dynamic data
const filterOptions = React.useMemo(() => { const filterOptions = React.useMemo(() => {
@@ -121,6 +189,56 @@ export function ProductFilters({
}); });
}, [categories, vendors]); }, [categories, vendors]);
// Filter options based on search
const filteredOptions = React.useMemo(() => {
if (!searchValue) return filterOptions;
const search = searchValue.toLowerCase();
return filterOptions.filter(option =>
option.label.toLowerCase().includes(search) ||
option.group.toLowerCase().includes(search)
);
}, [filterOptions, searchValue]);
const handleSelectFilter = React.useCallback((filter: FilterOption) => {
setSelectedFilter(filter);
setInputValue("");
}, []);
const handleApplyFilter = (value: FilterValue) => {
if (!selectedFilter) return;
const newFilters = {
...activeFilters,
[selectedFilter.id]: value
};
onFilterChange(newFilters);
setSelectedFilter(null);
setInputValue("");
setSearchValue("");
};
const handleKeyDown = React.useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter' && selectedFilter) {
if (selectedFilter.type === 'select') {
const option = selectedFilter.options?.find(opt =>
opt.label.toLowerCase().includes(inputValue.toLowerCase())
);
if (option) {
handleApplyFilter(option.value);
}
} else if (selectedFilter.type === 'number') {
const numValue = parseFloat(inputValue);
if (!isNaN(numValue)) {
handleApplyFilter(numValue);
}
} else if (selectedFilter.type === 'text' && inputValue.trim() !== '') {
handleApplyFilter(inputValue.trim());
}
}
}, [selectedFilter, inputValue]);
const activeFiltersList = React.useMemo(() => { const activeFiltersList = React.useMemo(() => {
if (!activeFilters) return []; if (!activeFilters) return [];
@@ -142,50 +260,46 @@ export function ProductFilters({
}); });
}, [activeFilters, filterOptions]); }, [activeFilters, filterOptions]);
const handleSelectFilter = (filter: FilterOption) => {
setSelectedFilter(filter);
if (filter.type === 'select') {
setOpen(false);
// Open a new command dialog for selecting the value
// This will be handled in a separate component
} else {
// For other types, we'll show an input field
setFilterValue("");
}
};
const handleRemoveFilter = (filterId: string) => {
const newFilters = { ...activeFilters };
delete newFilters[filterId];
onFilterChange(newFilters);
};
const handleApplyFilter = (value: FilterValue) => {
if (!selectedFilter) return;
const newFilters = {
...activeFilters,
[selectedFilter.id]: value
};
onFilterChange(newFilters);
setSelectedFilter(null);
setFilterValue("");
setOpen(false);
};
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="h-8 border-dashed" className="h-8 border-dashed"
onClick={() => setOpen(true)} onClick={() => {
setShowCommand(true);
setSelectedFilter(null);
setInputValue("");
setSearchValue("");
}}
> >
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Add Filter Add Filter
</Button> </Button>
{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={() => {
const newFilters = { ...activeFilters };
delete newFilters[filter.id];
onFilterChange(newFilters);
}}
>
<X className="h-3 w-3" />
</Button>
</Badge>
))}
{activeFiltersList.length > 0 && ( {activeFiltersList.length > 0 && (
<Button <Button
variant="ghost" variant="ghost"
@@ -193,89 +307,182 @@ export function ProductFilters({
className="h-8" className="h-8"
onClick={onClearFilters} onClick={onClearFilters}
> >
Clear Filters Clear All
</Button> </Button>
)} )}
</div> </div>
{activeFiltersList.length > 0 && ( {showCommand && (
<div className="flex flex-wrap gap-2"> <Command
{activeFiltersList.map((filter) => ( className="rounded-lg border"
<Badge shouldFilter={false}
key={filter.id} >
variant="secondary" {!selectedFilter ? (
className="flex items-center gap-1" <>
> <CommandInput
<span> placeholder="Search and select filters..."
{filter.label}: {filter.displayValue} value={searchValue}
</span> onValueChange={setSearchValue}
<Button />
variant="ghost" <CommandList>
size="sm" <CommandEmpty>No filters found.</CommandEmpty>
className="h-4 w-4 p-0 hover:bg-transparent" {Object.entries(
onClick={() => handleRemoveFilter(filter.id)} filteredOptions.reduce<Record<string, FilterOption[]>>((acc, filter) => {
> if (!acc[filter.group]) acc[filter.group] = [];
<X className="h-3 w-3" /> acc[filter.group].push(filter);
</Button> return acc;
</Badge> }, {})
))} ).map(([group, filters]) => (
</div> <React.Fragment key={group}>
)} <CommandGroup heading={group}>
{filters.map((filter) => (
<CommandDialog open={open} onOpenChange={setOpen}> <CommandItem
<Command className="rounded-lg border shadow-md"> key={filter.id}
<DialogTitle className="sr-only">Search Filters</DialogTitle> value={`${filter.id} ${filter.label}`}
<DialogDescription className="sr-only"> onSelect={() => {
Search and select filters to apply to the product list handleSelectFilter(filter);
</DialogDescription> if (filter.type !== 'select') {
<CommandInput placeholder="Search filters..." /> setInputValue("");
<CommandList> }
<CommandEmpty>No filters found.</CommandEmpty> }}
{Object.entries( className={cn(
filterOptions.reduce<Record<string, FilterOption[]>>((acc, filter) => { "cursor-pointer",
if (!acc[filter.group]) acc[filter.group] = []; activeFilters?.[filter.id] && "bg-accent"
acc[filter.group].push(filter); )}
return acc; >
}, {}) {filter.label}
).map(([group, filters]) => ( {activeFilters?.[filter.id] && (
<CommandGroup key={group} heading={group}> <span className="ml-auto text-xs text-muted-foreground">
{filters.map((filter) => ( Active
<CommandItem </span>
key={filter.id} )}
onSelect={() => handleSelectFilter(filter)} </CommandItem>
> ))}
{filter.label} </CommandGroup>
</CommandItem> <CommandSeparator />
</React.Fragment>
))} ))}
</CommandGroup> </CommandList>
))} </>
</CommandList> ) : selectedFilter.type === 'select' ? (
<>
<CommandInput
placeholder={`Select ${selectedFilter.label.toLowerCase()}...`}
value={inputValue}
onValueChange={setInputValue}
onKeyDown={(e) => {
if (e.key === 'Backspace' && !inputValue) {
e.preventDefault();
setSelectedFilter(null);
} else {
handleKeyDown(e);
}
}}
/>
<CommandList>
<CommandEmpty>No options found.</CommandEmpty>
<CommandGroup>
<CommandItem
onSelect={() => {
setSelectedFilter(null);
setInputValue("");
}}
className="cursor-pointer text-muted-foreground"
>
Back to filters
</CommandItem>
<CommandSeparator />
{selectedFilter.options
?.filter(option =>
option.label.toLowerCase().includes(inputValue.toLowerCase())
)
.map((option) => (
<CommandItem
key={option.value}
value={option.value}
onSelect={() => {
handleApplyFilter(option.value);
setShowCommand(false);
}}
className="cursor-pointer"
>
{option.label}
</CommandItem>
))
}
</CommandGroup>
</CommandList>
</>
) : (
<>
<CommandInput
placeholder={`Enter ${selectedFilter.label.toLowerCase()}`}
value={inputValue}
onValueChange={(value) => {
if (selectedFilter.type === 'number') {
if (/^\d*\.?\d*$/.test(value)) {
setInputValue(value);
}
} else {
setInputValue(value);
}
}}
onKeyDown={(e) => {
if (e.key === 'Backspace' && !inputValue) {
e.preventDefault();
setSelectedFilter(null);
} else if (e.key === 'Enter') {
if (selectedFilter.type === 'number') {
const numValue = parseFloat(inputValue);
if (!isNaN(numValue)) {
handleApplyFilter(numValue);
setShowCommand(false);
}
} else {
if (inputValue.trim() !== '') {
handleApplyFilter(inputValue.trim());
setShowCommand(false);
}
}
}
}}
/>
<CommandList>
<CommandGroup>
<CommandItem
onSelect={() => {
setSelectedFilter(null);
setInputValue("");
}}
className="cursor-pointer text-muted-foreground"
>
Back to filters
</CommandItem>
<CommandSeparator />
<CommandItem
onSelect={() => {
if (selectedFilter.type === 'number') {
const numValue = parseFloat(inputValue);
if (!isNaN(numValue)) {
handleApplyFilter(numValue);
setShowCommand(false);
}
} else {
if (inputValue.trim() !== '') {
handleApplyFilter(inputValue.trim());
setShowCommand(false);
}
}
}}
className="cursor-pointer"
>
Apply filter: {inputValue}
</CommandItem>
</CommandGroup>
</CommandList>
</>
)}
</Command> </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> </div>
); );

View File

@@ -1,4 +1,5 @@
import { ArrowUpDown } from "lucide-react"; import * as React from "react";
import { ArrowUpDown, GripVertical } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { import {
@@ -10,6 +11,24 @@ import {
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { cn } from "@/lib/utils";
import {
DndContext,
DragEndEvent,
DragOverlay,
DragStartEvent,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
SortableContext,
arrayMove,
horizontalListSortingStrategy,
useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
interface Product { interface Product {
product_id: string; product_id: string;
@@ -62,10 +81,12 @@ interface Product {
} }
interface ColumnDef { interface ColumnDef {
key: keyof Product; key: keyof Product | 'image';
label: string; label: string;
group: string; group: string;
format?: (value: any) => string | number; format?: (value: any) => string | number;
width?: string;
noLabel?: boolean;
} }
interface ProductTableProps { interface ProductTableProps {
@@ -75,6 +96,62 @@ interface ProductTableProps {
sortDirection: 'asc' | 'desc'; sortDirection: 'asc' | 'desc';
visibleColumns: Set<keyof Product>; visibleColumns: Set<keyof Product>;
columnDefs: ColumnDef[]; columnDefs: ColumnDef[];
onColumnOrderChange?: (columns: (keyof Product)[]) => void;
}
interface SortableHeaderProps {
column: keyof Product;
columnDef?: ColumnDef;
onSort: (column: keyof Product) => void;
sortColumn: keyof Product;
sortDirection: 'asc' | 'desc';
}
function SortableHeader({ column, columnDef, onSort, sortColumn, sortDirection }: SortableHeaderProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: column });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
if (columnDef?.noLabel) {
return <TableHead ref={setNodeRef} style={style} />;
}
return (
<TableHead
ref={setNodeRef}
style={style}
className={cn(
"cursor-pointer select-none",
columnDef?.width
)}
>
<div className="flex items-center gap-2">
<div {...attributes} {...listeners} className="cursor-grab">
<GripVertical className="h-4 w-4 text-muted-foreground" />
</div>
<div className="flex items-center gap-2" onClick={() => onSort(column)}>
{columnDef?.label ?? column}
{sortColumn === column && (
<ArrowUpDown className={cn(
"h-4 w-4",
sortDirection === 'desc' && "rotate-180 transform"
)} />
)}
</div>
</div>
</TableHead>
);
} }
export function ProductTable({ export function ProductTable({
@@ -84,7 +161,45 @@ export function ProductTable({
sortDirection, sortDirection,
visibleColumns, visibleColumns,
columnDefs, columnDefs,
onColumnOrderChange,
}: ProductTableProps) { }: ProductTableProps) {
const [activeId, setActiveId] = React.useState<keyof Product | null>(null);
const sensors = useSensors(
useSensor(MouseSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(TouchSensor, {
activationConstraint: {
delay: 200,
tolerance: 8,
},
})
);
// Get ordered visible columns
const orderedColumns = React.useMemo(() => {
return Array.from(visibleColumns);
}, [visibleColumns]);
const handleDragStart = (event: DragStartEvent) => {
setActiveId(event.active.id as keyof Product);
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
setActiveId(null);
if (over && active.id !== over.id) {
const oldIndex = orderedColumns.indexOf(active.id as keyof Product);
const newIndex = orderedColumns.indexOf(over.id as keyof Product);
const newOrder = arrayMove(orderedColumns, oldIndex, newIndex);
onColumnOrderChange?.(newOrder);
}
};
const getSortIcon = (column: keyof Product) => { const getSortIcon = (column: keyof Product) => {
if (sortColumn !== column) return <ArrowUpDown className="ml-2 h-4 w-4" />; if (sortColumn !== column) return <ArrowUpDown className="ml-2 h-4 w-4" />;
return ( return (
@@ -140,19 +255,24 @@ export function ProductTable({
} }
}; };
const formatColumnValue = (product: Product, column: ColumnDef) => { const formatColumnValue = (product: Product, column: keyof Product | 'image') => {
const value = product[column.key]; const value = column === 'image' ? product.image : product[column as keyof Product];
const columnDef = columnDefs.find(def => def.key === column);
// Special formatting for specific columns
switch (column.key) { switch (column) {
case 'image':
return product.image ? (
<img
src={product.image}
alt={product.title}
className="h-12 w-12 object-contain bg-white rounded border"
/>
) : null;
case 'title': case 'title':
return ( return (
<div className="flex items-center gap-2"> <div className="min-w-[300px]">
<Avatar className="h-8 w-8"> <div className="font-medium">{product.title}</div>
<AvatarImage src={product.image || undefined} alt={product.title} /> <div className="text-sm text-muted-foreground">{product.sku}</div>
<AvatarFallback>{product.title.charAt(0).toUpperCase()}</AvatarFallback>
</Avatar>
<span className="font-medium">{value as string}</span>
</div> </div>
); );
case 'categories': case 'categories':
@@ -176,51 +296,55 @@ export function ProductTable({
<Badge variant="outline">Hidden</Badge> <Badge variant="outline">Hidden</Badge>
); );
default: default:
if (column.format && value !== undefined && value !== null) { if (columnDef?.format && value !== undefined && value !== null) {
return column.format(value); return columnDef.format(value);
} }
return value || '-'; return value || '-';
} }
}; };
// Get visible column definitions in order
const visibleColumnDefs = columnDefs.filter(col => visibleColumns.has(col.key));
return ( return (
<div className="rounded-md border"> <div className="rounded-md border">
<Table> <Table>
<TableHeader> <DndContext
<TableRow> sensors={sensors}
{visibleColumnDefs.map((column) => ( onDragStart={handleDragStart}
<TableHead key={column.key}> onDragEnd={handleDragEnd}
<Button >
variant="ghost" <TableHeader>
onClick={() => onSort(column.key)} <TableRow>
> <SortableContext
{column.label} items={orderedColumns}
{getSortIcon(column.key)} strategy={horizontalListSortingStrategy}
</Button> >
</TableHead> {orderedColumns.map((column) => (
))} <SortableHeader
</TableRow> key={column}
</TableHeader> column={column}
<TableBody> columnDef={columnDefs.find(def => def.key === column)}
{products.map((product) => { onSort={onSort}
console.log('Rendering product:', product.product_id, product.title, product.categories); sortColumn={sortColumn}
return ( sortDirection={sortDirection}
<TableRow key={product.product_id}> />
{visibleColumnDefs.map((column) => (
<TableCell key={`${product.product_id}-${column.key}`}>
{formatColumnValue(product, column)}
</TableCell>
))} ))}
</TableRow> </SortableContext>
); </TableRow>
})} </TableHeader>
</DndContext>
<TableBody>
{products.map((product) => (
<TableRow key={product.product_id}>
{orderedColumns.map((column) => (
<TableCell key={`${product.product_id}-${column}`}>
{formatColumnValue(product, column)}
</TableCell>
))}
</TableRow>
))}
{!products.length && ( {!products.length && (
<TableRow> <TableRow>
<TableCell <TableCell
colSpan={visibleColumnDefs.length} colSpan={orderedColumns.length}
className="text-center py-8 text-muted-foreground" className="text-center py-8 text-muted-foreground"
> >
No products found No products found

View File

@@ -1,9 +1,8 @@
import { useState, useCallback, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import { useQuery, useQueryClient, keepPreviousData } from '@tanstack/react-query'; import { useQuery, useQueryClient, keepPreviousData } from '@tanstack/react-query';
import { ProductFilters } from '@/components/products/ProductFilters'; import { ProductFilters } 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 debounce from 'lodash/debounce';
import { import {
Pagination, Pagination,
PaginationContent, PaginationContent,
@@ -76,14 +75,6 @@ interface Product {
lead_time_status?: string; lead_time_status?: string;
} }
interface ProductFiltersState {
search: string;
category: string;
vendor: string;
stockStatus: string;
minPrice: string;
maxPrice: string;
}
// Column definition interface // Column definition interface
interface ColumnDef { interface ColumnDef {
@@ -169,6 +160,7 @@ export function Products() {
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [visibleColumns, setVisibleColumns] = useState<Set<keyof Product>>(new Set(DEFAULT_VISIBLE_COLUMNS)); const [visibleColumns, setVisibleColumns] = useState<Set<keyof Product>>(new Set(DEFAULT_VISIBLE_COLUMNS));
const [columnOrder, setColumnOrder] = useState<(keyof Product)[]>(DEFAULT_VISIBLE_COLUMNS);
// Group columns by their group property // Group columns by their group property
const columnsByGroup = AVAILABLE_COLUMNS.reduce((acc, col) => { const columnsByGroup = AVAILABLE_COLUMNS.reduce((acc, col) => {
@@ -283,6 +275,20 @@ export function Products() {
setPage(newPage); setPage(newPage);
}; };
// Update column order when visibility changes
useEffect(() => {
setColumnOrder(prev => {
const newOrder = prev.filter(col => visibleColumns.has(col));
const newColumns = Array.from(visibleColumns).filter(col => !prev.includes(col));
return [...newOrder, ...newColumns];
});
}, [visibleColumns]);
// Handle column reordering
const handleColumnOrderChange = (newOrder: (keyof Product)[]) => {
setColumnOrder(newOrder);
};
const renderPagination = () => { const renderPagination = () => {
if (!data?.pagination.pages || data.pagination.pages <= 1) return null; if (!data?.pagination.pages || data.pagination.pages <= 1) return null;
@@ -408,20 +414,18 @@ export function Products() {
</div> </div>
</div> </div>
<ProductFilters
filters={filters}
categories={data?.filters.categories ?? []}
vendors={data?.filters.vendors ?? []}
onFilterChange={handleFilterChange}
onClearFilters={handleClearFilters}
activeFilters={filters}
/>
<div ref={tableRef}> <div ref={tableRef}>
{isLoading ? ( <ProductFilters
<ProductTableSkeleton /> categories={data?.filters?.categories ?? []}
) : ( vendors={data?.filters?.vendors ?? []}
<> onFilterChange={handleFilterChange}
onClearFilters={handleClearFilters}
activeFilters={filters}
/>
<div className="mt-6">
{isLoading ? (
<ProductTableSkeleton />
) : (
<div className="relative"> <div className="relative">
{isFetching && ( {isFetching && (
<div className="absolute inset-0 bg-background/50 backdrop-blur-sm flex items-center justify-center z-50"> <div className="absolute inset-0 bg-background/50 backdrop-blur-sm flex items-center justify-center z-50">
@@ -430,16 +434,19 @@ export function Products() {
)} )}
<ProductTable <ProductTable
products={data?.products ?? []} products={data?.products ?? []}
onSort={handleSort} visibleColumns={new Set(columnOrder)}
sortColumn={sortColumn} sortColumn={sortColumn}
sortDirection={sortDirection} sortDirection={sortDirection}
visibleColumns={visibleColumns}
columnDefs={AVAILABLE_COLUMNS} columnDefs={AVAILABLE_COLUMNS}
onSort={handleSort}
onColumnOrderChange={handleColumnOrderChange}
/> />
</div> </div>
{renderPagination()} )}
</> </div>
)} </div>
<div className="mt-4">
{renderPagination()}
</div> </div>
</div> </div>
); );