diff --git a/inventory-server/src/routes/products.js b/inventory-server/src/routes/products.js index 5ab19e5..40d990c 100755 --- a/inventory-server/src/routes/products.js +++ b/inventory-server/src/routes/products.js @@ -70,6 +70,23 @@ router.get('/', async (req, res) => { 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)); @@ -80,6 +97,27 @@ router.get('/', async (req, res) => { 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)); @@ -90,6 +128,27 @@ router.get('/', async (req, res) => { 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)); @@ -110,6 +169,32 @@ router.get('/', async (req, res) => { 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 if (req.query.stockStatus && req.query.stockStatus !== 'all') { conditions.push('pm.stock_status = ?'); @@ -208,10 +293,10 @@ router.get('/', async (req, res) => { res.json({ products, pagination: { - page, - limit, total, - totalPages: Math.ceil(total / limit) + currentPage: page, + pages: Math.ceil(total / limit), + limit }, filters: { categories: categories.map(category => category.name), diff --git a/inventory/package-lock.json b/inventory/package-lock.json index 3bf5355..177e75a 100644 --- a/inventory/package-lock.json +++ b/inventory/package-lock.json @@ -8,6 +8,9 @@ "name": "inventory", "version": "0.0.0", "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-avatar": "^1.1.2", "@radix-ui/react-collapsible": "^1.1.2", @@ -386,6 +389,59 @@ "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": { "version": "0.24.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz", diff --git a/inventory/package.json b/inventory/package.json index 7f1eace..cb67f5b 100644 --- a/inventory/package.json +++ b/inventory/package.json @@ -10,6 +10,9 @@ "preview": "vite preview" }, "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-avatar": "^1.1.2", "@radix-ui/react-collapsible": "^1.1.2", diff --git a/inventory/src/components/products/ProductFilters.tsx b/inventory/src/components/products/ProductFilters.tsx index b2bf5f8..11e997a 100644 --- a/inventory/src/components/products/ProductFilters.tsx +++ b/inventory/src/components/products/ProductFilters.tsx @@ -3,15 +3,16 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Command, - CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, + CommandSeparator, } from "@/components/ui/command"; +import { Input } from "@/components/ui/input"; import { X, Plus } from "lucide-react"; -import { DialogTitle, DialogDescription } from "@/components/ui/dialog"; +import { cn } from "@/lib/utils"; type FilterValue = string | number | boolean; @@ -44,24 +45,43 @@ const FILTER_OPTIONS: FilterOption[] = [ 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' }, + { 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' }, + { 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 { 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' }, // 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' }, // Financial Metrics Group { 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: '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 { id: 'abcClass', @@ -77,10 +114,20 @@ const FILTER_OPTIONS: FilterOption[] = [ options: [ { label: 'A', value: 'A' }, { label: 'B', value: 'B' }, - { label: 'C', value: 'C' }, + { label: 'C', value: 'C' } ], group: 'Classification' }, + { + id: 'managingStock', + label: 'Managing Stock', + type: 'select', + options: [ + { label: 'Yes', value: 'true' }, + { label: 'No', value: 'false' } + ], + group: 'Classification' + } ]; interface ProductFiltersProps { @@ -98,9 +145,30 @@ export function ProductFilters({ onClearFilters, activeFilters, }: ProductFiltersProps) { - const [open, setOpen] = React.useState(false); + const [showCommand, setShowCommand] = React.useState(false); const [selectedFilter, setSelectedFilter] = React.useState(null); - const [filterValue, setFilterValue] = React.useState(""); + 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 const filterOptions = React.useMemo(() => { @@ -121,6 +189,56 @@ export function ProductFilters({ }); }, [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(() => { if (!activeFilters) return []; @@ -142,50 +260,46 @@ export function ProductFilters({ }); }, [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 (
-
+
+ {activeFiltersList.map((filter) => ( + + + {filter.label}: {filter.displayValue} + + + + ))} {activeFiltersList.length > 0 && ( )}
- {activeFiltersList.length > 0 && ( -
- {activeFiltersList.map((filter) => ( - - - {filter.label}: {filter.displayValue} - - - - ))} -
- )} - - - - Search Filters - - Search and select filters to apply to the product list - - - - No filters found. - {Object.entries( - filterOptions.reduce>((acc, filter) => { - if (!acc[filter.group]) acc[filter.group] = []; - acc[filter.group].push(filter); - return acc; - }, {}) - ).map(([group, filters]) => ( - - {filters.map((filter) => ( - handleSelectFilter(filter)} - > - {filter.label} - + {showCommand && ( + + {!selectedFilter ? ( + <> + + + No filters found. + {Object.entries( + filteredOptions.reduce>((acc, filter) => { + if (!acc[filter.group]) acc[filter.group] = []; + acc[filter.group].push(filter); + return acc; + }, {}) + ).map(([group, filters]) => ( + + + {filters.map((filter) => ( + { + handleSelectFilter(filter); + if (filter.type !== 'select') { + setInputValue(""); + } + }} + className={cn( + "cursor-pointer", + activeFilters?.[filter.id] && "bg-accent" + )} + > + {filter.label} + {activeFilters?.[filter.id] && ( + + Active + + )} + + ))} + + + ))} - - ))} - + + + ) : selectedFilter.type === 'select' ? ( + <> + { + if (e.key === 'Backspace' && !inputValue) { + e.preventDefault(); + setSelectedFilter(null); + } else { + handleKeyDown(e); + } + }} + /> + + No options found. + + { + setSelectedFilter(null); + setInputValue(""); + }} + className="cursor-pointer text-muted-foreground" + > + ← Back to filters + + + {selectedFilter.options + ?.filter(option => + option.label.toLowerCase().includes(inputValue.toLowerCase()) + ) + .map((option) => ( + { + handleApplyFilter(option.value); + setShowCommand(false); + }} + className="cursor-pointer" + > + {option.label} + + )) + } + + + + ) : ( + <> + { + 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); + } + } + } + }} + /> + + + { + setSelectedFilter(null); + setInputValue(""); + }} + className="cursor-pointer text-muted-foreground" + > + ← Back to filters + + + { + 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} + + + + + )} - - - {selectedFilter?.type === 'select' && ( - setSelectedFilter(null)}> - - Select {selectedFilter.label} - - Choose a value for the {selectedFilter.label.toLowerCase()} filter - - - - No options found. - - {selectedFilter.options?.map((option) => ( - handleApplyFilter(option.value)} - > - {option.label} - - ))} - - - - )}
); diff --git a/inventory/src/components/products/ProductTable.tsx b/inventory/src/components/products/ProductTable.tsx index 9618721..f112a69 100644 --- a/inventory/src/components/products/ProductTable.tsx +++ b/inventory/src/components/products/ProductTable.tsx @@ -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 { Badge } from "@/components/ui/badge"; import { @@ -10,6 +11,24 @@ import { TableRow, } from "@/components/ui/table"; 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 { product_id: string; @@ -62,10 +81,12 @@ interface Product { } interface ColumnDef { - key: keyof Product; + key: keyof Product | 'image'; label: string; group: string; format?: (value: any) => string | number; + width?: string; + noLabel?: boolean; } interface ProductTableProps { @@ -75,6 +96,62 @@ interface ProductTableProps { sortDirection: 'asc' | 'desc'; visibleColumns: Set; 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 ; + } + + return ( + +
+
+ +
+
onSort(column)}> + {columnDef?.label ?? column} + {sortColumn === column && ( + + )} +
+
+
+ ); } export function ProductTable({ @@ -84,7 +161,45 @@ export function ProductTable({ sortDirection, visibleColumns, columnDefs, + onColumnOrderChange, }: ProductTableProps) { + const [activeId, setActiveId] = React.useState(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) => { if (sortColumn !== column) return ; return ( @@ -140,19 +255,24 @@ export function ProductTable({ } }; - const formatColumnValue = (product: Product, column: ColumnDef) => { - const value = product[column.key]; - - // Special formatting for specific columns - switch (column.key) { + const formatColumnValue = (product: Product, column: keyof Product | 'image') => { + const value = column === 'image' ? product.image : product[column as keyof Product]; + const columnDef = columnDefs.find(def => def.key === column); + + switch (column) { + case 'image': + return product.image ? ( + {product.title} + ) : null; case 'title': return ( -
- - - {product.title.charAt(0).toUpperCase()} - - {value as string} +
+
{product.title}
+
{product.sku}
); case 'categories': @@ -176,51 +296,55 @@ export function ProductTable({ Hidden ); default: - if (column.format && value !== undefined && value !== null) { - return column.format(value); + if (columnDef?.format && value !== undefined && value !== null) { + return columnDef.format(value); } return value || '-'; } }; - // Get visible column definitions in order - const visibleColumnDefs = columnDefs.filter(col => visibleColumns.has(col.key)); - return (
- - - {visibleColumnDefs.map((column) => ( - - - - ))} - - - - {products.map((product) => { - console.log('Rendering product:', product.product_id, product.title, product.categories); - return ( - - {visibleColumnDefs.map((column) => ( - - {formatColumnValue(product, column)} - + + + + + {orderedColumns.map((column) => ( + def.key === column)} + onSort={onSort} + sortColumn={sortColumn} + sortDirection={sortDirection} + /> ))} - - ); - })} + + + + + + {products.map((product) => ( + + {orderedColumns.map((column) => ( + + {formatColumnValue(product, column)} + + ))} + + ))} {!products.length && ( No products found diff --git a/inventory/src/pages/Products.tsx b/inventory/src/pages/Products.tsx index 6c33c7b..4930b29 100644 --- a/inventory/src/pages/Products.tsx +++ b/inventory/src/pages/Products.tsx @@ -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 { ProductFilters } from '@/components/products/ProductFilters'; import { ProductTable } from '@/components/products/ProductTable'; import { ProductTableSkeleton } from '@/components/products/ProductTableSkeleton'; -import debounce from 'lodash/debounce'; import { Pagination, PaginationContent, @@ -76,14 +75,6 @@ interface Product { lead_time_status?: string; } -interface ProductFiltersState { - search: string; - category: string; - vendor: string; - stockStatus: string; - minPrice: string; - maxPrice: string; -} // Column definition interface interface ColumnDef { @@ -169,6 +160,7 @@ export function Products() { const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); const [page, setPage] = useState(1); const [visibleColumns, setVisibleColumns] = useState>(new Set(DEFAULT_VISIBLE_COLUMNS)); + const [columnOrder, setColumnOrder] = useState<(keyof Product)[]>(DEFAULT_VISIBLE_COLUMNS); // Group columns by their group property const columnsByGroup = AVAILABLE_COLUMNS.reduce((acc, col) => { @@ -283,6 +275,20 @@ export function Products() { 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 = () => { if (!data?.pagination.pages || data.pagination.pages <= 1) return null; @@ -408,20 +414,18 @@ export function Products() { - -
- {isLoading ? ( - - ) : ( - <> + +
+ {isLoading ? ( + + ) : (
{isFetching && (
@@ -430,16 +434,19 @@ export function Products() { )}
- {renderPagination()} - - )} + )} +
+
+
+ {renderPagination()}
);