From c57f69698bac08af51ec2dcc6e69493c90f1d9ad Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 13 Jan 2025 14:31:29 -0500 Subject: [PATCH] Restore images, more formatting and filter improvements --- inventory-server/src/routes/products.js | 6 +- .../components/products/ProductFilters.tsx | 411 +++++++++--------- .../src/components/products/ProductTable.tsx | 21 +- .../products/ProductTableSkeleton.tsx | 64 +-- inventory/src/pages/Products.tsx | 194 ++++----- 5 files changed, 344 insertions(+), 352 deletions(-) diff --git a/inventory-server/src/routes/products.js b/inventory-server/src/routes/products.js index 40d990c..338d843 100755 --- a/inventory-server/src/routes/products.js +++ b/inventory-server/src/routes/products.js @@ -218,6 +218,9 @@ router.get('/', async (req, res) => { const [vendors] = await pool.query( 'SELECT DISTINCT vendor FROM products WHERE visible = true AND vendor IS NOT NULL AND vendor != "" ORDER BY vendor' ); + const [brands] = await pool.query( + 'SELECT DISTINCT brand FROM products WHERE visible = true AND brand IS NOT NULL AND brand != "" ORDER BY brand' + ); // Main query with all fields const query = ` @@ -300,7 +303,8 @@ router.get('/', async (req, res) => { }, filters: { categories: categories.map(category => category.name), - vendors: vendors.map(vendor => vendor.vendor) + vendors: vendors.map(vendor => vendor.vendor), + brands: brands.map(brand => brand.brand) } }); } catch (error) { diff --git a/inventory/src/components/products/ProductFilters.tsx b/inventory/src/components/products/ProductFilters.tsx index 11e997a..f066810 100644 --- a/inventory/src/components/products/ProductFilters.tsx +++ b/inventory/src/components/products/ProductFilters.tsx @@ -10,9 +10,13 @@ import { CommandList, CommandSeparator, } from "@/components/ui/command"; -import { Input } from "@/components/ui/input"; import { X, Plus } from "lucide-react"; import { cn } from "@/lib/utils"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; type FilterValue = string | number | boolean; @@ -133,6 +137,7 @@ const FILTER_OPTIONS: FilterOption[] = [ interface ProductFiltersProps { categories: string[]; vendors: string[]; + brands: string[]; onFilterChange: (filters: Record) => void; onClearFilters: () => void; activeFilters: Record; @@ -141,6 +146,7 @@ interface ProductFiltersProps { export function ProductFilters({ categories, vendors, + brands, onFilterChange, onClearFilters, activeFilters, @@ -150,9 +156,15 @@ export function ProductFilters({ const [inputValue, setInputValue] = React.useState(""); const [searchValue, setSearchValue] = React.useState(""); - // Handle escape key + // Handle keyboard shortcuts React.useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { + // Command/Ctrl + K to toggle filter + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { + e.preventDefault(); + setShowCommand(prev => !prev); + } + // Escape to close or go back if (e.key === 'Escape') { if (selectedFilter) { setSelectedFilter(null); @@ -164,11 +176,9 @@ export function ProductFilters({ } }; - if (showCommand) { - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - } - }, [showCommand, selectedFilter]); + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [selectedFilter]); // Update filter options with dynamic data const filterOptions = React.useMemo(() => { @@ -185,9 +195,15 @@ export function ProductFilters({ options: vendors.map(vendor => ({ label: vendor, value: vendor })) }; } + if (option.id === 'brand') { + return { + ...option, + options: brands.map(brand => ({ label: brand, value: brand })) + }; + } return option; }); - }, [categories, vendors]); + }, [categories, vendors, brands]); // Filter options based on search const filteredOptions = React.useMemo(() => { @@ -263,20 +279,198 @@ export function ProductFilters({ return (
- + + + + + + + {!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} + + + + + )} + + + {activeFiltersList.map((filter) => ( )}
- - {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} - - - - - )} - - )}
); } \ No newline at end of file diff --git a/inventory/src/components/products/ProductTable.tsx b/inventory/src/components/products/ProductTable.tsx index f112a69..d845e7d 100644 --- a/inventory/src/components/products/ProductTable.tsx +++ b/inventory/src/components/products/ProductTable.tsx @@ -262,15 +262,17 @@ export function ProductTable({ switch (column) { case 'image': return product.image ? ( - {product.title} +
+ {product.title} +
) : null; case 'title': return ( -
+
{product.title}
{product.sku}
@@ -297,6 +299,13 @@ export function ProductTable({ ); default: if (columnDef?.format && value !== undefined && value !== null) { + // For numeric formats (those using toFixed), ensure the value is a number + if (typeof value === 'string') { + const num = parseFloat(value); + if (!isNaN(num)) { + return columnDef.format(num); + } + } return columnDef.format(value); } return value || '-'; diff --git a/inventory/src/components/products/ProductTableSkeleton.tsx b/inventory/src/components/products/ProductTableSkeleton.tsx index 96659ab..e76f346 100644 --- a/inventory/src/components/products/ProductTableSkeleton.tsx +++ b/inventory/src/components/products/ProductTableSkeleton.tsx @@ -1,58 +1,22 @@ import { Skeleton } from "@/components/ui/skeleton"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; export function ProductTableSkeleton() { return (
- - - - Product - SKU - Stock - Price - Regular Price - Cost - Vendor - Brand - Categories - Status - - - - {Array.from({ length: 20 }).map((_, i) => ( - - -
- - -
-
- - -
- - -
-
- - - - - - - -
- ))} -
-
+
+ {Array.from({ length: 20 }).map((_, i) => ( +
+ +
+ + +
+ + + +
+ ))} +
); } \ No newline at end of file diff --git a/inventory/src/pages/Products.tsx b/inventory/src/pages/Products.tsx index 4930b29..60bf2e1 100644 --- a/inventory/src/pages/Products.tsx +++ b/inventory/src/pages/Products.tsx @@ -78,14 +78,19 @@ interface Product { // Column definition interface interface ColumnDef { - key: keyof Product; + key: keyof Product | 'image'; label: string; group: string; format?: (value: any) => string | number; + width?: string; + noLabel?: boolean; } // Define available columns with grouping const AVAILABLE_COLUMNS: ColumnDef[] = [ + // Image (special column) + { key: 'image', label: 'Image', group: 'Basic Info', noLabel: true, width: 'w-[60px]' }, + // Basic Info Group { key: 'title', label: 'Title', group: 'Basic Info' }, { key: 'sku', label: 'SKU', group: 'Basic Info' }, @@ -96,26 +101,26 @@ const AVAILABLE_COLUMNS: ColumnDef[] = [ { key: 'barcode', label: 'Barcode', group: 'Basic Info' }, // Inventory Group - { key: 'stock_quantity', label: 'Stock Quantity', group: 'Inventory' }, + { key: 'stock_quantity', label: 'Stock Quantity', group: 'Inventory', format: (v) => v?.toString() ?? '-' }, { key: 'stock_status', label: 'Stock Status', group: 'Inventory' }, - { key: 'days_of_inventory', label: 'Days of Inventory', group: 'Inventory' }, - { key: 'reorder_point', label: 'Reorder Point', group: 'Inventory' }, - { key: 'safety_stock', label: 'Safety Stock', group: 'Inventory' }, - { key: 'moq', label: 'MOQ', group: 'Inventory' }, - { key: 'uom', label: 'UOM', group: 'Inventory' }, + { key: 'days_of_inventory', label: 'Days of Inventory', group: 'Inventory', format: (v) => v?.toFixed(1) ?? '-' }, + { key: 'reorder_point', label: 'Reorder Point', group: 'Inventory', format: (v) => v?.toString() ?? '-' }, + { key: 'safety_stock', label: 'Safety Stock', group: 'Inventory', format: (v) => v?.toString() ?? '-' }, + { key: 'moq', label: 'MOQ', group: 'Inventory', format: (v) => v?.toString() ?? '-' }, + { key: 'uom', label: 'UOM', group: 'Inventory', format: (v) => v?.toString() ?? '-' }, // Pricing Group - { key: 'price', label: 'Price', group: 'Pricing', format: (v) => v.toFixed(2) }, - { key: 'regular_price', label: 'Regular Price', group: 'Pricing', format: (v) => v.toFixed(2) }, - { key: 'cost_price', label: 'Cost Price', group: 'Pricing', format: (v) => v.toFixed(2) }, - { key: 'landing_cost_price', label: 'Landing Cost', group: 'Pricing', format: (v) => v.toFixed(2) }, + { key: 'price', label: 'Price', group: 'Pricing', format: (v) => v?.toFixed(2) ?? '-' }, + { key: 'regular_price', label: 'Regular Price', group: 'Pricing', format: (v) => v?.toFixed(2) ?? '-' }, + { key: 'cost_price', label: 'Cost Price', group: 'Pricing', format: (v) => v?.toFixed(2) ?? '-' }, + { key: 'landing_cost_price', label: 'Landing Cost', group: 'Pricing', format: (v) => v?.toFixed(2) ?? '-' }, // Sales Metrics Group { key: 'daily_sales_avg', label: 'Daily Sales Avg', group: 'Sales Metrics', format: (v) => v?.toFixed(2) ?? '-' }, { key: 'weekly_sales_avg', label: 'Weekly Sales Avg', group: 'Sales Metrics', format: (v) => v?.toFixed(2) ?? '-' }, { key: 'monthly_sales_avg', label: 'Monthly Sales Avg', group: 'Sales Metrics', format: (v) => v?.toFixed(2) ?? '-' }, { key: 'avg_quantity_per_order', label: 'Avg Qty per Order', group: 'Sales Metrics', format: (v) => v?.toFixed(1) ?? '-' }, - { key: 'number_of_orders', label: 'Number of Orders', group: 'Sales Metrics' }, + { key: 'number_of_orders', label: 'Number of Orders', group: 'Sales Metrics', format: (v) => v?.toString() ?? '-' }, { key: 'first_sale_date', label: 'First Sale', group: 'Sales Metrics' }, { key: 'last_sale_date', label: 'Last Sale', group: 'Sales Metrics' }, @@ -129,9 +134,9 @@ const AVAILABLE_COLUMNS: ColumnDef[] = [ { key: 'turnover_rate', label: 'Turnover Rate', group: 'Financial Metrics', format: (v) => v?.toFixed(2) ?? '-' }, // Purchase & Lead Time Group - { key: 'avg_lead_time_days', label: 'Avg Lead Time (Days)', group: 'Purchase & Lead Time' }, - { key: 'current_lead_time', label: 'Current Lead Time', group: 'Purchase & Lead Time' }, - { key: 'target_lead_time', label: 'Target Lead Time', group: 'Purchase & Lead Time' }, + { key: 'avg_lead_time_days', label: 'Avg Lead Time (Days)', group: 'Purchase & Lead Time', format: (v) => v?.toFixed(1) ?? '-' }, + { key: 'current_lead_time', label: 'Current Lead Time', group: 'Purchase & Lead Time', format: (v) => v?.toFixed(1) ?? '-' }, + { key: 'target_lead_time', label: 'Target Lead Time', group: 'Purchase & Lead Time', format: (v) => v?.toFixed(1) ?? '-' }, { key: 'lead_time_status', label: 'Lead Time Status', group: 'Purchase & Lead Time' }, { key: 'last_purchase_date', label: 'Last Purchase', group: 'Purchase & Lead Time' }, { key: 'last_received_date', label: 'Last Received', group: 'Purchase & Lead Time' }, @@ -141,7 +146,8 @@ const AVAILABLE_COLUMNS: ColumnDef[] = [ ]; // Default visible columns -const DEFAULT_VISIBLE_COLUMNS: (keyof Product)[] = [ +const DEFAULT_VISIBLE_COLUMNS: (keyof Product | 'image')[] = [ + 'image', 'title', 'sku', 'stock_quantity', @@ -154,13 +160,15 @@ const DEFAULT_VISIBLE_COLUMNS: (keyof Product)[] = [ export function Products() { const queryClient = useQueryClient(); - const tableRef = useRef(null); const [filters, setFilters] = useState>({}); const [sortColumn, setSortColumn] = useState('title'); 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); + const [visibleColumns, setVisibleColumns] = useState>(new Set(DEFAULT_VISIBLE_COLUMNS)); + const [columnOrder, setColumnOrder] = useState<(keyof Product | 'image')[]>([ + ...DEFAULT_VISIBLE_COLUMNS, + ...AVAILABLE_COLUMNS.map(col => col.key).filter(key => !DEFAULT_VISIBLE_COLUMNS.includes(key)) + ]); // Group columns by their group property const columnsByGroup = AVAILABLE_COLUMNS.reduce((acc, col) => { @@ -172,7 +180,7 @@ export function Products() { }, {} as Record); // Toggle column visibility - const toggleColumn = (columnKey: keyof Product) => { + const toggleColumn = (columnKey: keyof Product | 'image') => { setVisibleColumns(prev => { const next = new Set(prev); if (next.has(columnKey)) { @@ -244,13 +252,6 @@ export function Products() { } }, [page, data?.pagination, queryClient, filters, sortColumn, sortDirection]); - // Scroll to top when changing pages - useEffect(() => { - if (tableRef.current) { - tableRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' }); - } - }, [page]); - const handleSort = (column: keyof Product) => { if (sortColumn === column) { setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc'); @@ -272,21 +273,18 @@ export function Products() { }; const handlePageChange = (newPage: number) => { + window.scrollTo({ top: 0 }); setPage(newPage); }; - // Update column order when visibility changes - useEffect(() => { + // Handle column reordering from drag and drop + const handleColumnOrderChange = (newOrder: (keyof Product | 'image')[]) => { setColumnOrder(prev => { - const newOrder = prev.filter(col => visibleColumns.has(col)); - const newColumns = Array.from(visibleColumns).filter(col => !prev.includes(col)); - return [...newOrder, ...newColumns]; + // Keep hidden columns in their current positions + const newOrderSet = new Set(newOrder); + const hiddenColumns = prev.filter(col => !newOrderSet.has(col)); + return [...newOrder, ...hiddenColumns]; }); - }, [visibleColumns]); - - // Handle column reordering - const handleColumnOrderChange = (newOrder: (keyof Product)[]) => { - setColumnOrder(newOrder); }; const renderPagination = () => { @@ -376,72 +374,68 @@ export function Products() { return (
-
-

Products

-
-
- {data?.pagination.total.toLocaleString() ?? '...'} products -
- - - - - - Toggle Columns - - {Object.entries(columnsByGroup).map(([group, columns]) => ( -
- - {group} - - {columns.map((col) => ( - toggleColumn(col.key)} - > - {col.label} - - ))} - -
- ))} -
-
-
-
+

Products

-
- -
- {isLoading ? ( +
+
+ +
+ {data?.pagination.total && ( +
+ {data.pagination.total.toLocaleString()} products +
+ )} + + + + + + Toggle Columns + + {Object.entries(columnsByGroup).map(([group, columns]) => ( +
+ + {group} + + {columns.map((col) => ( + toggleColumn(col.key)} + > + {col.label} + + ))} + +
+ ))} +
+
+
+
+
+ {isLoading || isFetching ? ( ) : ( -
- {isFetching && ( -
-
Loading...
-
- )} - -
+ )}