diff --git a/inventory-server/src/routes/products.js b/inventory-server/src/routes/products.js index d513506..bfc6588 100755 --- a/inventory-server/src/routes/products.js +++ b/inventory-server/src/routes/products.js @@ -23,17 +23,17 @@ router.get('/', async (req, res) => { const sortDirection = req.query.sortDirection === 'desc' ? 'DESC' : 'ASC'; // Build the WHERE clause - const conditions = ['visible = true']; + const conditions = ['p.visible = true']; const params = []; if (search) { - conditions.push('(title LIKE ? OR SKU LIKE ?)'); + conditions.push('(p.title LIKE ? OR p.SKU LIKE ?)'); params.push(`%${search}%`, `%${search}%`); } if (category !== 'all') { conditions.push(` - product_id IN ( + p.product_id IN ( SELECT pc.product_id FROM product_categories pc JOIN categories c ON pc.category_id = c.id @@ -44,42 +44,42 @@ router.get('/', async (req, res) => { } if (vendor !== 'all') { - conditions.push('vendor = ?'); + conditions.push('p.vendor = ?'); params.push(vendor); } if (stockStatus !== 'all') { switch (stockStatus) { case 'out_of_stock': - conditions.push('stock_quantity = 0'); + conditions.push('p.stock_quantity = 0'); break; case 'low_stock': - conditions.push('stock_quantity > 0 AND stock_quantity <= 5'); + conditions.push('p.stock_quantity > 0 AND p.stock_quantity <= 5'); break; case 'in_stock': - conditions.push('stock_quantity > 5'); + conditions.push('p.stock_quantity > 5'); break; } } if (minPrice > 0) { - conditions.push('price >= ?'); + conditions.push('p.price >= ?'); params.push(minPrice); } if (maxPrice) { - conditions.push('price <= ?'); + conditions.push('p.price <= ?'); params.push(maxPrice); } // Get total count for pagination const [countResult] = await pool.query( - `SELECT COUNT(*) as total FROM products WHERE ${conditions.join(' AND ')}`, + `SELECT COUNT(*) as total FROM products p WHERE ${conditions.join(' AND ')}`, params ); const total = countResult[0].total; - // Get paginated results + // Get paginated results with metrics const query = ` SELECT p.product_id, @@ -89,15 +89,50 @@ router.get('/', async (req, res) => { p.price, p.regular_price, p.cost_price, + p.landing_cost_price, + p.barcode, p.vendor, + p.vendor_reference, p.brand, p.visible, p.managing_stock, + p.replenishable, + p.moq, + p.uom, p.image, - GROUP_CONCAT(c.name) as categories + GROUP_CONCAT(DISTINCT c.name) as categories, + + -- Metrics from product_metrics + pm.daily_sales_avg, + pm.weekly_sales_avg, + pm.monthly_sales_avg, + pm.avg_quantity_per_order, + pm.number_of_orders, + pm.first_sale_date, + pm.last_sale_date, + pm.days_of_inventory, + pm.weeks_of_inventory, + pm.reorder_point, + pm.safety_stock, + pm.avg_margin_percent, + pm.total_revenue, + pm.inventory_value, + pm.cost_of_goods_sold, + pm.gross_profit, + pm.gmroi, + pm.avg_lead_time_days, + pm.last_purchase_date, + pm.last_received_date, + pm.abc_class, + pm.stock_status, + pm.turnover_rate, + pm.current_lead_time, + pm.target_lead_time, + pm.lead_time_status FROM products p LEFT JOIN product_categories pc ON p.product_id = pc.product_id LEFT JOIN categories c ON pc.category_id = c.id + LEFT JOIN product_metrics pm ON p.product_id = pm.product_id WHERE ${conditions.join(' AND ')} GROUP BY p.product_id ORDER BY ${sortColumn} ${sortDirection} @@ -106,10 +141,38 @@ router.get('/', async (req, res) => { const [rows] = await pool.query(query, [...params, limit, offset]); - // Transform the categories string into an array + // Transform the categories string into an array and parse numeric values const productsWithCategories = rows.map(product => ({ ...product, - categories: product.categories ? product.categories.split(',') : [] + categories: product.categories ? [...new Set(product.categories.split(','))] : [], + // Parse numeric values + price: parseFloat(product.price) || 0, + regular_price: parseFloat(product.regular_price) || 0, + cost_price: parseFloat(product.cost_price) || 0, + landing_cost_price: parseFloat(product.landing_cost_price) || 0, + stock_quantity: parseInt(product.stock_quantity) || 0, + moq: parseInt(product.moq) || 1, + uom: parseInt(product.uom) || 1, + // Parse metrics + daily_sales_avg: parseFloat(product.daily_sales_avg) || null, + weekly_sales_avg: parseFloat(product.weekly_sales_avg) || null, + monthly_sales_avg: parseFloat(product.monthly_sales_avg) || null, + avg_quantity_per_order: parseFloat(product.avg_quantity_per_order) || null, + number_of_orders: parseInt(product.number_of_orders) || null, + days_of_inventory: parseInt(product.days_of_inventory) || null, + weeks_of_inventory: parseInt(product.weeks_of_inventory) || null, + reorder_point: parseInt(product.reorder_point) || null, + safety_stock: parseInt(product.safety_stock) || null, + avg_margin_percent: parseFloat(product.avg_margin_percent) || null, + total_revenue: parseFloat(product.total_revenue) || null, + inventory_value: parseFloat(product.inventory_value) || null, + cost_of_goods_sold: parseFloat(product.cost_of_goods_sold) || null, + gross_profit: parseFloat(product.gross_profit) || null, + gmroi: parseFloat(product.gmroi) || null, + turnover_rate: parseFloat(product.turnover_rate) || null, + avg_lead_time_days: parseInt(product.avg_lead_time_days) || null, + current_lead_time: parseInt(product.current_lead_time) || null, + target_lead_time: parseInt(product.target_lead_time) || null })); // Get unique categories and vendors for filters diff --git a/inventory-server/src/server.js b/inventory-server/src/server.js index 8efe20e..228f186 100755 --- a/inventory-server/src/server.js +++ b/inventory-server/src/server.js @@ -79,7 +79,7 @@ const pool = initPool({ app.locals.pool = pool; // Routes -app.use('/api/dashboard/products', productsRouter); +app.use('/api/products', productsRouter); app.use('/api/dashboard', dashboardRouter); app.use('/api/orders', ordersRouter); app.use('/api/csv', csvRouter); diff --git a/inventory/src/components/layout/AppSidebar.tsx b/inventory/src/components/layout/AppSidebar.tsx index 9a692a7..9af2f25 100644 --- a/inventory/src/components/layout/AppSidebar.tsx +++ b/inventory/src/components/layout/AppSidebar.tsx @@ -2,7 +2,6 @@ import { Home, Package, ShoppingCart, BarChart2, Settings, Box, ClipboardList } import { Sidebar, SidebarContent, - SidebarFooter, SidebarGroup, SidebarGroupContent, SidebarHeader, diff --git a/inventory/src/components/products/ProductTable.tsx b/inventory/src/components/products/ProductTable.tsx index fde26a6..9618721 100644 --- a/inventory/src/components/products/ProductTable.tsx +++ b/inventory/src/components/products/ProductTable.tsx @@ -19,19 +19,62 @@ interface Product { price: number; regular_price: number; cost_price: number; + landing_cost_price: number; + barcode: string; vendor: string; + vendor_reference: string; brand: string; categories: string[]; + image: string | null; + moq: number; + uom: number; visible: boolean; managing_stock: boolean; - image?: string; + replenishable: boolean; + + // Metrics + daily_sales_avg?: number; + weekly_sales_avg?: number; + monthly_sales_avg?: number; + avg_quantity_per_order?: number; + number_of_orders?: number; + first_sale_date?: string; + last_sale_date?: string; + days_of_inventory?: number; + weeks_of_inventory?: number; + reorder_point?: number; + safety_stock?: number; + avg_margin_percent?: number; + total_revenue?: number; + inventory_value?: number; + cost_of_goods_sold?: number; + gross_profit?: number; + gmroi?: number; + avg_lead_time_days?: number; + last_purchase_date?: string; + last_received_date?: string; + abc_class?: string; + stock_status?: string; + turnover_rate?: number; + current_lead_time?: number; + target_lead_time?: number; + lead_time_status?: string; +} + +interface ColumnDef { + key: keyof Product; + label: string; + group: string; + format?: (value: any) => string | number; } interface ProductTableProps { products: Product[]; onSort: (column: keyof Product) => void; - sortColumn: keyof Product | null; + sortColumn: keyof Product; sortDirection: 'asc' | 'desc'; + visibleColumns: Set; + columnDefs: ColumnDef[]; } export function ProductTable({ @@ -39,6 +82,8 @@ export function ProductTable({ onSort, sortColumn, sortDirection, + visibleColumns, + columnDefs, }: ProductTableProps) { const getSortIcon = (column: keyof Product) => { if (sortColumn !== column) return ; @@ -49,149 +94,135 @@ export function ProductTable({ ); }; - const getStockStatus = (quantity: number) => { - if (quantity === 0) { - return Out of Stock; + const getStockStatus = (status: string | undefined) => { + if (!status) return null; + switch (status.toLowerCase()) { + case 'critical': + return Critical; + case 'reorder': + return Reorder; + case 'healthy': + return Healthy; + case 'overstocked': + return Overstocked; + case 'new': + return New; + default: + return null; } - if (quantity <= 5) { - return Low Stock; - } - return In Stock; }; + const getABCClass = (abcClass: string | undefined) => { + if (!abcClass) return null; + switch (abcClass.toUpperCase()) { + case 'A': + return A; + case 'B': + return B; + case 'C': + return C; + default: + return null; + } + }; + + const getLeadTimeStatus = (status: string | undefined) => { + if (!status) return null; + switch (status.toLowerCase()) { + case 'critical': + return Critical; + case 'warning': + return Warning; + case 'good': + return Good; + default: + return null; + } + }; + + const formatColumnValue = (product: Product, column: ColumnDef) => { + const value = product[column.key]; + + // Special formatting for specific columns + switch (column.key) { + case 'title': + return ( +
+ + + {product.title.charAt(0).toUpperCase()} + + {value as string} +
+ ); + case 'categories': + return ( +
+ {Array.from(new Set(value as string[])).map((category) => ( + {category} + )) || '-'} +
+ ); + case 'stock_status': + return getStockStatus(value as string); + case 'abc_class': + return getABCClass(value as string); + case 'lead_time_status': + return getLeadTimeStatus(value as string); + case 'visible': + return value ? ( + Active + ) : ( + Hidden + ); + default: + if (column.format && value !== undefined && value !== null) { + return column.format(value); + } + return value || '-'; + } + }; + + // Get visible column definitions in order + const visibleColumnDefs = columnDefs.filter(col => visibleColumns.has(col.key)); return (
- - - - - - - - - - - - - - - - - - - - - - - - - - - - Status + {visibleColumnDefs.map((column) => ( + + + + ))} - {products.map((product) => ( - - -
- - - {product.title.charAt(0).toUpperCase()} - - {product.title} -
-
- {product.sku} - -
- {product.stock_quantity} - {getStockStatus(product.stock_quantity)} -
-
- ${product.price.toFixed(2)} - ${product.regular_price.toFixed(2)} - ${product.cost_price.toFixed(2)} - {product.vendor || '-'} - {product.brand || '-'} - -
- {product.categories?.map((category) => ( - {category} - )) || '-'} -
-
- - {product.visible ? ( - Active - ) : ( - Hidden - )} - -
- ))} + {products.map((product) => { + console.log('Rendering product:', product.product_id, product.title, product.categories); + return ( + + {visibleColumnDefs.map((column) => ( + + {formatColumnValue(product, column)} + + ))} + + ); + })} {!products.length && ( - + No products found diff --git a/inventory/src/components/ui/dropdown-menu.tsx b/inventory/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..9ff6568 --- /dev/null +++ b/inventory/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,199 @@ +import * as React from "react" +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" +import { Check, ChevronRight, Circle } from "lucide-react" + +import { cn } from "@/lib/utils" + +const DropdownMenu = DropdownMenuPrimitive.Root + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger + +const DropdownMenuGroup = DropdownMenuPrimitive.Group + +const DropdownMenuPortal = DropdownMenuPrimitive.Portal + +const DropdownMenuSub = DropdownMenuPrimitive.Sub + +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup + +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)) +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)) +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + svg]:size-4 [&>svg]:shrink-0", + inset && "pl-8", + className + )} + {...props} + /> +)) +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName + +const DropdownMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ) +} +DropdownMenuShortcut.displayName = "DropdownMenuShortcut" + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +} diff --git a/inventory/src/pages/Products.tsx b/inventory/src/pages/Products.tsx index 91c89a8..db49b58 100644 --- a/inventory/src/pages/Products.tsx +++ b/inventory/src/pages/Products.tsx @@ -12,9 +12,21 @@ import { PaginationNext, PaginationPrevious, } from "@/components/ui/pagination"; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Button } from "@/components/ui/button"; +import { Settings2 } from "lucide-react"; import config from '../config'; +// Enhanced Product interface with all possible fields interface Product { + // Basic product info (from products table) product_id: string; title: string; sku: string; @@ -22,12 +34,46 @@ interface Product { price: number; regular_price: number; cost_price: number; + landing_cost_price: number; + barcode: string; vendor: string; + vendor_reference: string; brand: string; categories: string[]; + image: string | null; + moq: number; + uom: number; visible: boolean; managing_stock: boolean; - image: string | null; + replenishable: boolean; + + // Metrics (from product_metrics table) + daily_sales_avg?: number; + weekly_sales_avg?: number; + monthly_sales_avg?: number; + avg_quantity_per_order?: number; + number_of_orders?: number; + first_sale_date?: string; + last_sale_date?: string; + days_of_inventory?: number; + weeks_of_inventory?: number; + reorder_point?: number; + safety_stock?: number; + avg_margin_percent?: number; + total_revenue?: number; + inventory_value?: number; + cost_of_goods_sold?: number; + gross_profit?: number; + gmroi?: number; + avg_lead_time_days?: number; + last_purchase_date?: string; + last_received_date?: string; + abc_class?: string; + stock_status?: string; + turnover_rate?: number; + current_lead_time?: number; + target_lead_time?: number; + lead_time_status?: string; } interface ProductFiltersState { @@ -39,6 +85,82 @@ interface ProductFiltersState { maxPrice: string; } +// Column definition interface +interface ColumnDef { + key: keyof Product; + label: string; + group: string; + format?: (value: any) => string | number; +} + +// Define available columns with grouping +const AVAILABLE_COLUMNS: ColumnDef[] = [ + // Basic Info Group + { key: 'title', label: 'Title', group: 'Basic Info' }, + { key: 'sku', label: 'SKU', group: 'Basic Info' }, + { key: 'brand', label: 'Brand', group: 'Basic Info' }, + { key: 'categories', label: 'Categories', group: 'Basic Info' }, + { key: 'vendor', label: 'Vendor', group: 'Basic Info' }, + { key: 'vendor_reference', label: 'Vendor Reference', group: 'Basic Info' }, + { key: 'barcode', label: 'Barcode', group: 'Basic Info' }, + + // Inventory Group + { key: 'stock_quantity', label: 'Stock Quantity', group: 'Inventory' }, + { 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' }, + + // 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) }, + + // 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: 'first_sale_date', label: 'First Sale', group: 'Sales Metrics' }, + { key: 'last_sale_date', label: 'Last Sale', group: 'Sales Metrics' }, + + // Financial Metrics Group + { key: 'avg_margin_percent', label: 'Avg Margin %', group: 'Financial Metrics', format: (v) => v ? `${v.toFixed(1)}%` : '-' }, + { key: 'total_revenue', label: 'Total Revenue', group: 'Financial Metrics', format: (v) => v?.toFixed(2) ?? '-' }, + { key: 'inventory_value', label: 'Inventory Value', group: 'Financial Metrics', format: (v) => v?.toFixed(2) ?? '-' }, + { key: 'cost_of_goods_sold', label: 'COGS', group: 'Financial Metrics', format: (v) => v?.toFixed(2) ?? '-' }, + { key: 'gross_profit', label: 'Gross Profit', group: 'Financial Metrics', format: (v) => v?.toFixed(2) ?? '-' }, + { key: 'gmroi', label: 'GMROI', group: 'Financial Metrics', format: (v) => v?.toFixed(2) ?? '-' }, + { 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: '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' }, + + // Classification Group + { key: 'abc_class', label: 'ABC Class', group: 'Classification' }, +]; + +// Default visible columns +const DEFAULT_VISIBLE_COLUMNS: (keyof Product)[] = [ + 'title', + 'sku', + 'stock_quantity', + 'stock_status', + 'price', + 'vendor', + 'brand', + 'categories', +]; + export function Products() { const queryClient = useQueryClient(); const tableRef = useRef(null); @@ -53,6 +175,29 @@ export function Products() { 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)); + + // Group columns by their group property + const columnsByGroup = AVAILABLE_COLUMNS.reduce((acc, col) => { + if (!acc[col.group]) { + acc[col.group] = []; + } + acc[col.group].push(col); + return acc; + }, {} as Record); + + // Toggle column visibility + const toggleColumn = (columnKey: keyof Product) => { + setVisibleColumns(prev => { + const next = new Set(prev); + if (next.has(columnKey)) { + next.delete(columnKey); + } else { + next.add(columnKey); + } + return next; + }); + }; // Function to fetch products data const fetchProducts = async (pageNum: number) => { @@ -288,8 +433,39 @@ export function Products() {

Products

-
- {data?.pagination.total.toLocaleString() ?? '...'} products +
+
+ {data?.pagination.total.toLocaleString() ?? '...'} products +
+ + + + + + Toggle Columns + + {Object.entries(columnsByGroup).map(([group, columns]) => ( +
+ + {group} + + {columns.map((col) => ( + toggleColumn(col.key)} + > + {col.label} + + ))} + +
+ ))} +
+
@@ -317,6 +493,8 @@ export function Products() { onSort={handleSort} sortColumn={sortColumn} sortDirection={sortDirection} + visibleColumns={visibleColumns} + columnDefs={AVAILABLE_COLUMNS} />
{renderPagination()}