diff --git a/inventory-server/scripts/metrics-new/backfill/populate_initial_product_metrics.sql b/inventory-server/scripts/metrics-new/backfill/populate_initial_product_metrics.sql index 360624f..2833868 100644 --- a/inventory-server/scripts/metrics-new/backfill/populate_initial_product_metrics.sql +++ b/inventory-server/scripts/metrics-new/backfill/populate_initial_product_metrics.sql @@ -100,27 +100,27 @@ BEGIN AVG(CASE WHEN snapshot_date BETWEEN _calculation_date - INTERVAL '29 days' AND _calculation_date THEN eod_stock_retail END) AS avg_stock_retail_30d, AVG(CASE WHEN snapshot_date BETWEEN _calculation_date - INTERVAL '29 days' AND _calculation_date THEN eod_stock_gross END) AS avg_stock_gross_30d, - -- Lifetime (Using product.total_sold instead of snapshot summation for historical accuracy) - p.historical_total_sold AS lifetime_sales, + -- Lifetime (Using historical total from products table) + (SELECT total_sold FROM public.products WHERE public.products.pid = daily_product_snapshots.pid) AS lifetime_sales, COALESCE( -- Option 1: Use 30-day average price if available CASE WHEN SUM(CASE WHEN snapshot_date >= _calculation_date - INTERVAL '29 days' AND snapshot_date <= _calculation_date THEN units_sold ELSE 0 END) > 0 THEN - p.historical_total_sold * ( + (SELECT total_sold FROM public.products WHERE public.products.pid = daily_product_snapshots.pid) * ( SUM(CASE WHEN snapshot_date >= _calculation_date - INTERVAL '29 days' AND snapshot_date <= _calculation_date THEN net_revenue ELSE 0 END) / NULLIF(SUM(CASE WHEN snapshot_date >= _calculation_date - INTERVAL '29 days' AND snapshot_date <= _calculation_date THEN units_sold ELSE 0 END), 0) ) ELSE NULL END, -- Option 2: Try 365-day average price if available CASE WHEN SUM(CASE WHEN snapshot_date >= _calculation_date - INTERVAL '364 days' AND snapshot_date <= _calculation_date THEN units_sold ELSE 0 END) > 0 THEN - p.historical_total_sold * ( + (SELECT total_sold FROM public.products WHERE public.products.pid = daily_product_snapshots.pid) * ( SUM(CASE WHEN snapshot_date >= _calculation_date - INTERVAL '364 days' AND snapshot_date <= _calculation_date THEN net_revenue ELSE 0 END) / NULLIF(SUM(CASE WHEN snapshot_date >= _calculation_date - INTERVAL '364 days' AND snapshot_date <= _calculation_date THEN units_sold ELSE 0 END), 0) ) ELSE NULL END, -- Option 3: Use current price from products table - p.historical_total_sold * p.current_price, + (SELECT total_sold * price FROM public.products WHERE public.products.pid = daily_product_snapshots.pid), -- Option 4: Use regular price if current price might be zero - p.historical_total_sold * p.current_regular_price, + (SELECT total_sold * regular_price FROM public.products WHERE public.products.pid = daily_product_snapshots.pid), -- Final fallback: Use accumulated revenue (less accurate for old products) SUM(net_revenue) ) AS lifetime_revenue, diff --git a/inventory-server/src/routes/analytics.js b/inventory-server/src/routes/analytics.js index 01a7a3d..ecd9e83 100644 --- a/inventory-server/src/routes/analytics.js +++ b/inventory-server/src/routes/analytics.js @@ -7,37 +7,33 @@ router.get('/stats', async (req, res) => { const pool = req.app.locals.pool; const { rows: [results] } = await pool.query(` - SELECT - COALESCE( - ROUND( - (SUM(o.price * o.quantity - p.cost_price * o.quantity) / - NULLIF(SUM(o.price * o.quantity), 0) * 100)::numeric, 1 - ), - 0 - ) as profitMargin, - COALESCE( - ROUND( - (AVG(p.price / NULLIF(p.cost_price, 0) - 1) * 100)::numeric, 1 - ), - 0 - ) as averageMarkup, - COALESCE( - ROUND( - (SUM(o.quantity) / NULLIF(AVG(p.stock_quantity), 0))::numeric, 2 - ), - 0 - ) as stockTurnoverRate, - COALESCE(COUNT(DISTINCT p.vendor), 0) as vendorCount, - COALESCE(COUNT(DISTINCT p.categories), 0) as categoryCount, - COALESCE( - ROUND( - AVG(o.price * o.quantity)::numeric, 2 - ), - 0 - ) as averageOrderValue - FROM products p - LEFT JOIN orders o ON p.pid = o.pid - WHERE o.date >= CURRENT_DATE - INTERVAL '30 days' + WITH vendor_count AS ( + SELECT COUNT(DISTINCT vendor_name) AS count + FROM vendor_metrics + ), + category_count AS ( + SELECT COUNT(DISTINCT category_id) AS count + FROM category_metrics + ), + metrics_summary AS ( + SELECT + AVG(margin_30d) AS avg_profit_margin, + AVG(markup_30d) AS avg_markup, + AVG(stockturn_30d) AS avg_stock_turnover, + AVG(asp_30d) AS avg_order_value + FROM product_metrics + WHERE sales_30d > 0 + ) + SELECT + COALESCE(ms.avg_profit_margin, 0) AS profitMargin, + COALESCE(ms.avg_markup, 0) AS averageMarkup, + COALESCE(ms.avg_stock_turnover, 0) AS stockTurnoverRate, + COALESCE(vc.count, 0) AS vendorCount, + COALESCE(cc.count, 0) AS categoryCount, + COALESCE(ms.avg_order_value, 0) AS averageOrderValue + FROM metrics_summary ms + CROSS JOIN vendor_count vc + CROSS JOIN category_count cc `); // Ensure all values are numbers @@ -84,43 +80,53 @@ router.get('/profit', async (req, res) => { JOIN category_path cp ON c.parent_id = cp.cat_id ) SELECT - c.name as category, - cp.path as categoryPath, - ROUND( - (SUM(o.price * o.quantity - p.cost_price * o.quantity) / - NULLIF(SUM(o.price * o.quantity), 0) * 100)::numeric, 1 - ) as profitMargin, - ROUND(SUM(o.price * o.quantity)::numeric, 3) as revenue, - ROUND(SUM(p.cost_price * o.quantity)::numeric, 3) as cost - FROM products p - LEFT JOIN orders o ON p.pid = o.pid - JOIN product_categories pc ON p.pid = pc.pid - JOIN categories c ON pc.cat_id = c.cat_id - JOIN category_path cp ON c.cat_id = cp.cat_id - WHERE o.date >= CURRENT_DATE - INTERVAL '30 days' - GROUP BY c.name, cp.path - ORDER BY profitMargin DESC + cm.category_name as category, + COALESCE(cp.path, cm.category_name) as categorypath, + cm.avg_margin_30d as profitmargin, + cm.revenue_30d as revenue, + cm.cogs_30d as cost + FROM category_metrics cm + LEFT JOIN category_path cp ON cm.category_id = cp.cat_id + WHERE cm.revenue_30d > 0 + ORDER BY cm.revenue_30d DESC LIMIT 10 `); - // Get profit margin trend over time + // Get profit margin over time const { rows: overTime } = await pool.query(` - SELECT - to_char(o.date, 'YYYY-MM-DD') as date, - ROUND( - (SUM(o.price * o.quantity - p.cost_price * o.quantity) / - NULLIF(SUM(o.price * o.quantity), 0) * 100)::numeric, 1 - ) as profitMargin, - ROUND(SUM(o.price * o.quantity)::numeric, 3) as revenue, - ROUND(SUM(p.cost_price * o.quantity)::numeric, 3) as cost - FROM products p - LEFT JOIN orders o ON p.pid = o.pid - WHERE o.date >= CURRENT_DATE - INTERVAL '30 days' - GROUP BY to_char(o.date, 'YYYY-MM-DD') - ORDER BY date + WITH time_series AS ( + SELECT + date_trunc('day', generate_series( + CURRENT_DATE - INTERVAL '30 days', + CURRENT_DATE, + '1 day'::interval + ))::date AS date + ), + daily_profits AS ( + SELECT + snapshot_date as date, + SUM(net_revenue) as revenue, + SUM(cogs) as cost, + CASE + WHEN SUM(net_revenue) > 0 + THEN (SUM(net_revenue - cogs) / SUM(net_revenue)) * 100 + ELSE 0 + END as profit_margin + FROM daily_product_snapshots + WHERE snapshot_date >= CURRENT_DATE - INTERVAL '30 days' + GROUP BY snapshot_date + ) + SELECT + to_char(ts.date, 'YYYY-MM-DD') as date, + COALESCE(dp.profit_margin, 0) as profitmargin, + COALESCE(dp.revenue, 0) as revenue, + COALESCE(dp.cost, 0) as cost + FROM time_series ts + LEFT JOIN daily_profits dp ON ts.date = dp.date + ORDER BY ts.date `); - // Get top performing products with category paths + // Get top performing products by profit margin const { rows: topProducts } = await pool.query(` WITH RECURSIVE category_path AS ( SELECT @@ -140,26 +146,28 @@ router.get('/profit', async (req, res) => { (cp.path || ' > ' || c.name)::text FROM categories c JOIN category_path cp ON c.parent_id = cp.cat_id + ), + product_categories AS ( + SELECT + pc.pid, + c.name as category, + COALESCE(cp.path, c.name) as categorypath + FROM product_categories pc + JOIN categories c ON pc.cat_id = c.cat_id + LEFT JOIN category_path cp ON c.cat_id = cp.cat_id ) SELECT - p.title as product, - c.name as category, - cp.path as categoryPath, - ROUND( - (SUM(o.price * o.quantity - p.cost_price * o.quantity) / - NULLIF(SUM(o.price * o.quantity), 0) * 100)::numeric, 1 - ) as profitMargin, - ROUND(SUM(o.price * o.quantity)::numeric, 3) as revenue, - ROUND(SUM(p.cost_price * o.quantity)::numeric, 3) as cost - FROM products p - LEFT JOIN orders o ON p.pid = o.pid - JOIN product_categories pc ON p.pid = pc.pid - JOIN categories c ON pc.cat_id = c.cat_id - JOIN category_path cp ON c.cat_id = cp.cat_id - WHERE o.date >= CURRENT_DATE - INTERVAL '30 days' - GROUP BY p.pid, p.title, c.name, cp.path - HAVING SUM(o.price * o.quantity) > 0 - ORDER BY profitMargin DESC + pm.title as product, + COALESCE(pc.category, 'Uncategorized') as category, + COALESCE(pc.categorypath, 'Uncategorized') as categorypath, + pm.margin_30d as profitmargin, + pm.revenue_30d as revenue, + pm.cogs_30d as cost + FROM product_metrics pm + LEFT JOIN product_categories pc ON pm.pid = pc.pid + WHERE pm.revenue_30d > 100 + AND pm.margin_30d > 0 + ORDER BY pm.margin_30d DESC LIMIT 10 `); @@ -184,93 +192,52 @@ router.get('/vendors', async (req, res) => { console.log('Fetching vendor performance data...'); - // First check if we have any vendors with sales - const { rows: [checkData] } = await pool.query(` - SELECT COUNT(DISTINCT p.vendor) as vendor_count, - COUNT(DISTINCT o.order_number) as order_count - FROM products p - LEFT JOIN orders o ON p.pid = o.pid - WHERE p.vendor IS NOT NULL - `); - - console.log('Vendor data check:', checkData); - - // Get vendor performance metrics + // Get vendor performance metrics from the vendor_metrics table const { rows: rawPerformance } = await pool.query(` - WITH monthly_sales AS ( - SELECT - p.vendor, - ROUND(SUM(CASE - WHEN o.date >= CURRENT_DATE - INTERVAL '30 days' - THEN o.price * o.quantity - ELSE 0 - END)::numeric, 3) as current_month, - ROUND(SUM(CASE - WHEN o.date >= CURRENT_DATE - INTERVAL '60 days' - AND o.date < CURRENT_DATE - INTERVAL '30 days' - THEN o.price * o.quantity - ELSE 0 - END)::numeric, 3) as previous_month - FROM products p - LEFT JOIN orders o ON p.pid = o.pid - WHERE p.vendor IS NOT NULL - AND o.date >= CURRENT_DATE - INTERVAL '60 days' - GROUP BY p.vendor - ) SELECT - p.vendor, - ROUND(SUM(o.price * o.quantity)::numeric, 3) as sales_volume, - COALESCE(ROUND( - (SUM(o.price * o.quantity - p.cost_price * o.quantity) / - NULLIF(SUM(o.price * o.quantity), 0) * 100)::numeric, 1 - ), 0) as profit_margin, - COALESCE(ROUND( - (SUM(o.quantity) / NULLIF(AVG(p.stock_quantity), 0))::numeric, 1 - ), 0) as stock_turnover, - COUNT(DISTINCT p.pid) as product_count, - ROUND( - ((ms.current_month / NULLIF(ms.previous_month, 0)) - 1) * 100, - 1 - ) as growth - FROM products p - LEFT JOIN orders o ON p.pid = o.pid - LEFT JOIN monthly_sales ms ON p.vendor = ms.vendor - WHERE p.vendor IS NOT NULL - AND o.date >= CURRENT_DATE - INTERVAL '30 days' - GROUP BY p.vendor, ms.current_month, ms.previous_month - ORDER BY sales_volume DESC - LIMIT 10 + vendor_name as vendor, + revenue_30d as sales_volume, + avg_margin_30d as profit_margin, + COALESCE( + sales_30d / NULLIF(current_stock_units, 0), + 0 + ) as stock_turnover, + product_count, + -- Use an estimate of growth based on 7-day vs 30-day revenue + CASE + WHEN revenue_30d > 0 + THEN ((revenue_7d * 4.0) / revenue_30d - 1) * 100 + ELSE 0 + END as growth + FROM vendor_metrics + WHERE revenue_30d > 0 + ORDER BY revenue_30d DESC + LIMIT 20 `); - // Transform to camelCase properties for frontend consumption - const performance = rawPerformance.map(item => ({ - vendor: item.vendor, - salesVolume: Number(item.sales_volume) || 0, - profitMargin: Number(item.profit_margin) || 0, - stockTurnover: Number(item.stock_turnover) || 0, - productCount: Number(item.product_count) || 0, - growth: Number(item.growth) || 0 + // Format the performance data + const performance = rawPerformance.map(vendor => ({ + vendor: vendor.vendor, + salesVolume: Number(vendor.sales_volume) || 0, + profitMargin: Number(vendor.profit_margin) || 0, + stockTurnover: Number(vendor.stock_turnover) || 0, + productCount: Number(vendor.product_count) || 0, + growth: Number(vendor.growth) || 0 })); // Get vendor comparison metrics (sales per product vs margin) const { rows: rawComparison } = await pool.query(` SELECT - p.vendor, - COALESCE(ROUND( - SUM(o.price * o.quantity) / NULLIF(COUNT(DISTINCT p.pid), 0), - 2 - ), 0) as sales_per_product, - COALESCE(ROUND( - AVG((p.price - p.cost_price) / NULLIF(p.cost_price, 0) * 100), - 2 - ), 0) as average_margin, - COUNT(DISTINCT p.pid) as size - FROM products p - LEFT JOIN orders o ON p.pid = o.pid - WHERE p.vendor IS NOT NULL - AND o.date >= CURRENT_DATE - INTERVAL '30 days' - GROUP BY p.vendor - HAVING COUNT(DISTINCT p.pid) > 0 + vendor_name as vendor, + CASE + WHEN active_product_count > 0 + THEN revenue_30d / active_product_count + ELSE 0 + END as sales_per_product, + avg_margin_30d as average_margin, + product_count as size + FROM vendor_metrics + WHERE active_product_count > 0 ORDER BY sales_per_product DESC LIMIT 10 `); @@ -294,58 +261,7 @@ router.get('/vendors', async (req, res) => { }); } catch (error) { console.error('Error fetching vendor performance:', error); - console.error('Error details:', error.message); - - // Return dummy data on error with complete structure - res.json({ - performance: [ - { - vendor: "Example Vendor 1", - salesVolume: 10000, - profitMargin: 25.5, - stockTurnover: 3.2, - productCount: 15, - growth: 12.3 - }, - { - vendor: "Example Vendor 2", - salesVolume: 8500, - profitMargin: 22.8, - stockTurnover: 2.9, - productCount: 12, - growth: 8.7 - }, - { - vendor: "Example Vendor 3", - salesVolume: 6200, - profitMargin: 19.5, - stockTurnover: 2.5, - productCount: 8, - growth: 5.2 - } - ], - comparison: [ - { - vendor: "Example Vendor 1", - salesPerProduct: 650, - averageMargin: 35.2, - size: 15 - }, - { - vendor: "Example Vendor 2", - salesPerProduct: 710, - averageMargin: 28.5, - size: 12 - }, - { - vendor: "Example Vendor 3", - salesPerProduct: 770, - averageMargin: 22.8, - size: 8 - } - ], - trends: [] - }); + res.status(500).json({ error: 'Failed to fetch vendor performance data' }); } }); @@ -353,108 +269,119 @@ router.get('/vendors', async (req, res) => { router.get('/stock', async (req, res) => { try { const pool = req.app.locals.pool; + console.log('Fetching stock analysis data...'); - // Get global configuration values - const { rows: configs } = await pool.query(` - SELECT - st.low_stock_threshold, - tc.calculation_period_days as turnover_period - FROM stock_thresholds st - CROSS JOIN turnover_config tc - WHERE st.id = 1 AND tc.id = 1 - `); - - const config = configs[0] || { - low_stock_threshold: 5, - turnover_period: 30 - }; + // Use the new metrics tables to get data // Get turnover by category const { rows: turnoverByCategory } = await pool.query(` - SELECT - c.name as category, - ROUND((SUM(o.quantity) / NULLIF(AVG(p.stock_quantity), 0))::numeric, 1) as turnoverRate, - ROUND(AVG(p.stock_quantity)::numeric, 0) as averageStock, - SUM(o.quantity) as totalSales - FROM products p - LEFT JOIN orders o ON p.pid = o.pid - JOIN product_categories pc ON p.pid = pc.pid - JOIN categories c ON pc.cat_id = c.cat_id - WHERE o.date >= CURRENT_DATE - INTERVAL '${config.turnover_period} days' - GROUP BY c.name - HAVING ROUND((SUM(o.quantity) / NULLIF(AVG(p.stock_quantity), 0))::numeric, 1) > 0 - ORDER BY turnoverRate DESC - LIMIT 10 - `); - - // Get stock levels over time - const { rows: stockLevels } = await pool.query(` - SELECT - to_char(o.date, 'YYYY-MM-DD') as date, - SUM(CASE WHEN p.stock_quantity > $1 THEN 1 ELSE 0 END) as inStock, - SUM(CASE WHEN p.stock_quantity <= $1 AND p.stock_quantity > 0 THEN 1 ELSE 0 END) as lowStock, - SUM(CASE WHEN p.stock_quantity = 0 THEN 1 ELSE 0 END) as outOfStock - FROM products p - LEFT JOIN orders o ON p.pid = o.pid - WHERE o.date >= CURRENT_DATE - INTERVAL '${config.turnover_period} days' - GROUP BY to_char(o.date, 'YYYY-MM-DD') - ORDER BY date - `, [config.low_stock_threshold]); - - // Get critical stock items - const { rows: criticalItems } = await pool.query(` - WITH product_thresholds AS ( + WITH category_metrics_with_path AS ( + WITH RECURSIVE category_path AS ( + SELECT + c.cat_id, + c.name, + c.parent_id, + c.name::text as path + FROM categories c + WHERE c.parent_id IS NULL + + UNION ALL + + SELECT + c.cat_id, + c.name, + c.parent_id, + (cp.path || ' > ' || c.name)::text + FROM categories c + JOIN category_path cp ON c.parent_id = cp.cat_id + ) SELECT - p.pid, - COALESCE( - (SELECT reorder_days - FROM stock_thresholds st - WHERE st.vendor = p.vendor LIMIT 1), - (SELECT reorder_days - FROM stock_thresholds st - WHERE st.vendor IS NULL LIMIT 1), - 14 - ) as reorder_days - FROM products p + cm.category_id, + cm.category_name, + cp.path as category_path, + cm.current_stock_units, + cm.sales_30d, + cm.stock_turn_30d + FROM category_metrics cm + LEFT JOIN category_path cp ON cm.category_id = cp.cat_id + WHERE cm.sales_30d > 0 ) SELECT - p.title as product, - p.SKU as sku, - p.stock_quantity as stockQuantity, - GREATEST(ROUND((AVG(o.quantity) * pt.reorder_days)::numeric), $1) as reorderPoint, - ROUND((SUM(o.quantity) / NULLIF(p.stock_quantity, 0))::numeric, 1) as turnoverRate, - CASE - WHEN p.stock_quantity = 0 THEN 0 - ELSE ROUND((p.stock_quantity / NULLIF((SUM(o.quantity) / $2), 0))::numeric) - END as daysUntilStockout - FROM products p - LEFT JOIN orders o ON p.pid = o.pid - JOIN product_thresholds pt ON p.pid = pt.pid - WHERE o.date >= CURRENT_DATE - INTERVAL '${config.turnover_period} days' - AND p.managing_stock = true - GROUP BY p.pid, pt.reorder_days - HAVING - CASE - WHEN p.stock_quantity = 0 THEN 0 - ELSE ROUND((p.stock_quantity / NULLIF((SUM(o.quantity) / $2), 0))::numeric) - END < $3 - AND - CASE - WHEN p.stock_quantity = 0 THEN 0 - ELSE ROUND((p.stock_quantity / NULLIF((SUM(o.quantity) / $2), 0))::numeric) - END >= 0 - ORDER BY daysUntilStockout + category_name as category, + COALESCE(stock_turn_30d, 0) as turnoverRate, + current_stock_units as averageStock, + sales_30d as totalSales + FROM category_metrics_with_path + ORDER BY stock_turn_30d DESC NULLS LAST LIMIT 10 - `, [ - config.low_stock_threshold, - config.turnover_period, - config.turnover_period - ]); - - res.json({ turnoverByCategory, stockLevels, criticalItems }); + `); + + // Get stock levels over time (last 30 days) + const { rows: stockLevels } = await pool.query(` + WITH date_range AS ( + SELECT generate_series( + CURRENT_DATE - INTERVAL '30 days', + CURRENT_DATE, + '1 day'::interval + )::date AS date + ), + daily_stock_counts AS ( + SELECT + snapshot_date, + COUNT(DISTINCT pid) as total_products, + COUNT(DISTINCT CASE WHEN eod_stock_quantity > 5 THEN pid END) as in_stock, + COUNT(DISTINCT CASE WHEN eod_stock_quantity <= 5 AND eod_stock_quantity > 0 THEN pid END) as low_stock, + COUNT(DISTINCT CASE WHEN eod_stock_quantity = 0 THEN pid END) as out_of_stock + FROM daily_product_snapshots + WHERE snapshot_date >= CURRENT_DATE - INTERVAL '30 days' + GROUP BY snapshot_date + ) + SELECT + to_char(dr.date, 'YYYY-MM-DD') as date, + COALESCE(dsc.in_stock, 0) as inStock, + COALESCE(dsc.low_stock, 0) as lowStock, + COALESCE(dsc.out_of_stock, 0) as outOfStock + FROM date_range dr + LEFT JOIN daily_stock_counts dsc ON dr.date = dsc.snapshot_date + ORDER BY dr.date + `); + + // Get critical items (products that need reordering) + const { rows: criticalItems } = await pool.query(` + SELECT + pm.title as product, + pm.sku as sku, + pm.current_stock as stockQuantity, + COALESCE(pm.config_safety_stock, 0) as reorderPoint, + COALESCE(pm.stockturn_30d, 0) as turnoverRate, + CASE + WHEN pm.sales_velocity_daily > 0 + THEN ROUND(pm.current_stock / pm.sales_velocity_daily) + ELSE 999 + END as daysUntilStockout + FROM product_metrics pm + WHERE pm.is_visible = true + AND pm.is_replenishable = true + AND pm.sales_30d > 0 + AND pm.current_stock <= pm.config_safety_stock * 2 + ORDER BY + CASE + WHEN pm.sales_velocity_daily > 0 + THEN pm.current_stock / pm.sales_velocity_daily + ELSE 999 + END ASC, + pm.revenue_30d DESC + LIMIT 10 + `); + + res.json({ + turnoverByCategory, + stockLevels, + criticalItems + }); } catch (error) { console.error('Error fetching stock analysis:', error); - res.status(500).json({ error: 'Failed to fetch stock analysis' }); + res.status(500).json({ error: 'Failed to fetch stock analysis', details: error.message }); } }); diff --git a/inventory-server/src/routes/dashboard.js b/inventory-server/src/routes/dashboard.js index d9ce948..1c1cec5 100644 --- a/inventory-server/src/routes/dashboard.js +++ b/inventory-server/src/routes/dashboard.js @@ -22,11 +22,11 @@ router.get('/stock/metrics', async (req, res) => { const { rows: [stockMetrics] } = await executeQuery(` SELECT COALESCE(COUNT(*), 0)::integer as total_products, - COALESCE(COUNT(CASE WHEN stock_quantity > 0 THEN 1 END), 0)::integer as products_in_stock, - COALESCE(SUM(CASE WHEN stock_quantity > 0 THEN stock_quantity END), 0)::integer as total_units, - ROUND(COALESCE(SUM(CASE WHEN stock_quantity > 0 THEN stock_quantity * cost_price END), 0)::numeric, 3) as total_cost, - ROUND(COALESCE(SUM(CASE WHEN stock_quantity > 0 THEN stock_quantity * price END), 0)::numeric, 3) as total_retail - FROM products + COALESCE(COUNT(CASE WHEN current_stock > 0 THEN 1 END), 0)::integer as products_in_stock, + COALESCE(SUM(CASE WHEN current_stock > 0 THEN current_stock END), 0)::integer as total_units, + ROUND(COALESCE(SUM(CASE WHEN current_stock > 0 THEN current_stock_cost END), 0)::numeric, 3) as total_cost, + ROUND(COALESCE(SUM(CASE WHEN current_stock > 0 THEN current_stock_retail END), 0)::numeric, 3) as total_retail + FROM product_metrics `); console.log('Raw stockMetrics from database:', stockMetrics); @@ -42,13 +42,13 @@ router.get('/stock/metrics', async (req, res) => { SELECT COALESCE(brand, 'Unbranded') as brand, COUNT(DISTINCT pid)::integer as variant_count, - COALESCE(SUM(stock_quantity), 0)::integer as stock_units, - ROUND(COALESCE(SUM(stock_quantity * cost_price), 0)::numeric, 3) as stock_cost, - ROUND(COALESCE(SUM(stock_quantity * price), 0)::numeric, 3) as stock_retail - FROM products - WHERE stock_quantity > 0 + COALESCE(SUM(current_stock), 0)::integer as stock_units, + ROUND(COALESCE(SUM(current_stock_cost), 0)::numeric, 3) as stock_cost, + ROUND(COALESCE(SUM(current_stock_retail), 0)::numeric, 3) as stock_retail + FROM product_metrics + WHERE current_stock > 0 GROUP BY COALESCE(brand, 'Unbranded') - HAVING ROUND(COALESCE(SUM(stock_quantity * cost_price), 0)::numeric, 3) > 0 + HAVING ROUND(COALESCE(SUM(current_stock_cost), 0)::numeric, 3) > 0 ), other_brands AS ( SELECT @@ -130,11 +130,11 @@ router.get('/purchase/metrics', async (req, res) => { END), 0)::numeric, 3) as total_cost, ROUND(COALESCE(SUM(CASE WHEN po.receiving_status NOT IN ('partial_received', 'full_received', 'paid') - THEN po.ordered * p.price + THEN po.ordered * pm.current_price ELSE 0 END), 0)::numeric, 3) as total_retail FROM purchase_orders po - JOIN products p ON po.pid = p.pid + JOIN product_metrics pm ON po.pid = pm.pid `); const { rows: vendorOrders } = await executeQuery(` @@ -143,9 +143,9 @@ router.get('/purchase/metrics', async (req, res) => { COUNT(DISTINCT po.po_id)::integer as orders, COALESCE(SUM(po.ordered), 0)::integer as units, ROUND(COALESCE(SUM(po.ordered * po.cost_price), 0)::numeric, 3) as cost, - ROUND(COALESCE(SUM(po.ordered * p.price), 0)::numeric, 3) as retail + ROUND(COALESCE(SUM(po.ordered * pm.current_price), 0)::numeric, 3) as retail FROM purchase_orders po - JOIN products p ON po.pid = p.pid + JOIN product_metrics pm ON po.pid = pm.pid WHERE po.receiving_status NOT IN ('partial_received', 'full_received', 'paid') GROUP BY po.vendor HAVING ROUND(COALESCE(SUM(po.ordered * po.cost_price), 0)::numeric, 3) > 0 @@ -223,54 +223,35 @@ router.get('/replenishment/metrics', async (req, res) => { // Get summary metrics const { rows: [metrics] } = await executeQuery(` SELECT - COUNT(DISTINCT p.pid)::integer as products_to_replenish, - COALESCE(SUM(CASE - WHEN p.stock_quantity < 0 THEN ABS(p.stock_quantity) + pm.reorder_qty - ELSE pm.reorder_qty - END), 0)::integer as total_units_needed, - ROUND(COALESCE(SUM(CASE - WHEN p.stock_quantity < 0 THEN (ABS(p.stock_quantity) + pm.reorder_qty) * p.cost_price - ELSE pm.reorder_qty * p.cost_price - END), 0)::numeric, 3) as total_cost, - ROUND(COALESCE(SUM(CASE - WHEN p.stock_quantity < 0 THEN (ABS(p.stock_quantity) + pm.reorder_qty) * p.price - ELSE pm.reorder_qty * p.price - END), 0)::numeric, 3) as total_retail - FROM products p - JOIN product_metrics pm ON p.pid = pm.pid - WHERE p.replenishable = true - AND (pm.stock_status IN ('Critical', 'Reorder') - OR p.stock_quantity < 0) - AND pm.reorder_qty > 0 + COUNT(DISTINCT pm.pid)::integer as products_to_replenish, + COALESCE(SUM(pm.replenishment_units), 0)::integer as total_units_needed, + ROUND(COALESCE(SUM(pm.replenishment_cost), 0)::numeric, 3) as total_cost, + ROUND(COALESCE(SUM(pm.replenishment_retail), 0)::numeric, 3) as total_retail + FROM product_metrics pm + WHERE pm.is_replenishable = true + AND (pm.status IN ('Critical', 'Reorder') + OR pm.current_stock < 0) + AND pm.replenishment_units > 0 `); // Get top variants to replenish const { rows: variants } = await executeQuery(` SELECT - p.pid, - p.title, - p.stock_quantity::integer as current_stock, - CASE - WHEN p.stock_quantity < 0 THEN ABS(p.stock_quantity) + pm.reorder_qty - ELSE pm.reorder_qty - END::integer as replenish_qty, - ROUND(CASE - WHEN p.stock_quantity < 0 THEN (ABS(p.stock_quantity) + pm.reorder_qty) * p.cost_price - ELSE pm.reorder_qty * p.cost_price - END::numeric, 3) as replenish_cost, - ROUND(CASE - WHEN p.stock_quantity < 0 THEN (ABS(p.stock_quantity) + pm.reorder_qty) * p.price - ELSE pm.reorder_qty * p.price - END::numeric, 3) as replenish_retail, - pm.stock_status - FROM products p - JOIN product_metrics pm ON p.pid = pm.pid - WHERE p.replenishable = true - AND (pm.stock_status IN ('Critical', 'Reorder') - OR p.stock_quantity < 0) - AND pm.reorder_qty > 0 + pm.pid, + pm.title, + pm.current_stock::integer as current_stock, + pm.replenishment_units::integer as replenish_qty, + ROUND(pm.replenishment_cost::numeric, 3) as replenish_cost, + ROUND(pm.replenishment_retail::numeric, 3) as replenish_retail, + pm.status, + pm.planning_period_days::text as planning_period + FROM product_metrics pm + WHERE pm.is_replenishable = true + AND (pm.status IN ('Critical', 'Reorder') + OR pm.current_stock < 0) + AND pm.replenishment_units > 0 ORDER BY - CASE pm.stock_status + CASE pm.status WHEN 'Critical' THEN 1 WHEN 'Reorder' THEN 2 END, @@ -280,7 +261,7 @@ router.get('/replenishment/metrics', async (req, res) => { // If no data, provide dummy data if (!metrics || variants.length === 0) { - console.log('No replenishment metrics found, returning dummy data'); + console.log('No replenishment metrics found in new schema, returning dummy data'); return res.json({ productsToReplenish: 15, @@ -288,11 +269,11 @@ router.get('/replenishment/metrics', async (req, res) => { replenishmentCost: 15000.00, replenishmentRetail: 30000.00, topVariants: [ - { id: 1, title: "Test Product 1", currentStock: 5, replenishQty: 20, replenishCost: 500, replenishRetail: 1000, status: "Critical" }, - { id: 2, title: "Test Product 2", currentStock: 10, replenishQty: 15, replenishCost: 450, replenishRetail: 900, status: "Critical" }, - { id: 3, title: "Test Product 3", currentStock: 15, replenishQty: 10, replenishCost: 300, replenishRetail: 600, status: "Reorder" }, - { id: 4, title: "Test Product 4", currentStock: 20, replenishQty: 20, replenishCost: 200, replenishRetail: 400, status: "Reorder" }, - { id: 5, title: "Test Product 5", currentStock: 25, replenishQty: 10, replenishCost: 150, replenishRetail: 300, status: "Reorder" } + { id: 1, title: "Test Product 1", currentStock: 5, replenishQty: 20, replenishCost: 500, replenishRetail: 1000, status: "Critical", planningPeriod: "30" }, + { id: 2, title: "Test Product 2", currentStock: 10, replenishQty: 15, replenishCost: 450, replenishRetail: 900, status: "Critical", planningPeriod: "30" }, + { id: 3, title: "Test Product 3", currentStock: 15, replenishQty: 10, replenishCost: 300, replenishRetail: 600, status: "Reorder", planningPeriod: "30" }, + { id: 4, title: "Test Product 4", currentStock: 20, replenishQty: 20, replenishCost: 200, replenishRetail: 400, status: "Reorder", planningPeriod: "30" }, + { id: 5, title: "Test Product 5", currentStock: 25, replenishQty: 10, replenishCost: 150, replenishRetail: 300, status: "Reorder", planningPeriod: "30" } ] }); } @@ -310,7 +291,8 @@ router.get('/replenishment/metrics', async (req, res) => { replenishQty: parseInt(v.replenish_qty) || 0, replenishCost: parseFloat(v.replenish_cost) || 0, replenishRetail: parseFloat(v.replenish_retail) || 0, - status: v.stock_status + status: v.status, + planningPeriod: v.planning_period })) }; @@ -325,11 +307,11 @@ router.get('/replenishment/metrics', async (req, res) => { replenishmentCost: 15000.00, replenishmentRetail: 30000.00, topVariants: [ - { id: 1, title: "Test Product 1", currentStock: 5, replenishQty: 20, replenishCost: 500, replenishRetail: 1000, status: "Critical" }, - { id: 2, title: "Test Product 2", currentStock: 10, replenishQty: 15, replenishCost: 450, replenishRetail: 900, status: "Critical" }, - { id: 3, title: "Test Product 3", currentStock: 15, replenishQty: 10, replenishCost: 300, replenishRetail: 600, status: "Reorder" }, - { id: 4, title: "Test Product 4", currentStock: 20, replenishQty: 20, replenishCost: 200, replenishRetail: 400, status: "Reorder" }, - { id: 5, title: "Test Product 5", currentStock: 25, replenishQty: 10, replenishCost: 150, replenishRetail: 300, status: "Reorder" } + { id: 1, title: "Test Product 1", currentStock: 5, replenishQty: 20, replenishCost: 500, replenishRetail: 1000, status: "Critical", planningPeriod: "30" }, + { id: 2, title: "Test Product 2", currentStock: 10, replenishQty: 15, replenishCost: 450, replenishRetail: 900, status: "Critical", planningPeriod: "30" }, + { id: 3, title: "Test Product 3", currentStock: 15, replenishQty: 10, replenishCost: 300, replenishRetail: 600, status: "Reorder", planningPeriod: "30" }, + { id: 4, title: "Test Product 4", currentStock: 20, replenishQty: 20, replenishCost: 200, replenishRetail: 400, status: "Reorder", planningPeriod: "30" }, + { id: 5, title: "Test Product 5", currentStock: 25, replenishQty: 10, replenishCost: 150, replenishRetail: 300, status: "Reorder", planningPeriod: "30" } ] }); } @@ -499,74 +481,15 @@ router.get('/forecast/metrics', async (req, res) => { // Returns overstock metrics by category router.get('/overstock/metrics', async (req, res) => { try { - const { rows } = await executeQuery(` - WITH category_overstock AS ( - SELECT - c.cat_id, - c.name as category_name, - COUNT(DISTINCT CASE - WHEN pm.stock_status = 'Overstocked' - THEN p.pid - END) as overstocked_products, - SUM(CASE - WHEN pm.stock_status = 'Overstocked' - THEN pm.overstocked_amt - ELSE 0 - END) as total_excess_units, - SUM(CASE - WHEN pm.stock_status = 'Overstocked' - THEN pm.overstocked_amt * p.cost_price - ELSE 0 - END) as total_excess_cost, - SUM(CASE - WHEN pm.stock_status = 'Overstocked' - THEN pm.overstocked_amt * p.price - ELSE 0 - END) as total_excess_retail - FROM categories c - JOIN product_categories pc ON c.cat_id = pc.cat_id - JOIN products p ON pc.pid = p.pid - JOIN product_metrics pm ON p.pid = pm.pid - GROUP BY c.cat_id, c.name - ), - filtered_categories AS ( - SELECT * - FROM category_overstock - WHERE overstocked_products > 0 - ORDER BY total_excess_cost DESC - LIMIT 8 - ), - summary AS ( - SELECT - SUM(overstocked_products) as total_overstocked, - SUM(total_excess_units) as total_excess_units, - SUM(total_excess_cost) as total_excess_cost, - SUM(total_excess_retail) as total_excess_retail - FROM filtered_categories - ) - SELECT - s.total_overstocked, - s.total_excess_units, - s.total_excess_cost, - s.total_excess_retail, - json_agg( - json_build_object( - 'category', fc.category_name, - 'products', fc.overstocked_products, - 'units', fc.total_excess_units, - 'cost', fc.total_excess_cost, - 'retail', fc.total_excess_retail - ) - ) as category_data - FROM summary s, filtered_categories fc - GROUP BY - s.total_overstocked, - s.total_excess_units, - s.total_excess_cost, - s.total_excess_retail + // Check if we have any products with Overstock status + const { rows: [countCheck] } = await executeQuery(` + SELECT COUNT(*) as overstock_count FROM product_metrics WHERE status = 'Overstock' `); - - if (rows.length === 0) { + + console.log('Overstock count:', countCheck.overstock_count); + + // If no overstock products, return empty metrics + if (parseInt(countCheck.overstock_count) === 0) { return res.json({ overstockedProducts: 0, total_excess_units: 0, @@ -575,31 +498,51 @@ router.get('/overstock/metrics', async (req, res) => { category_data: [] }); } + + // Get summary metrics in a simpler, more direct query + const { rows: [summaryMetrics] } = await executeQuery(` + SELECT + COUNT(DISTINCT pid)::integer as total_overstocked, + SUM(overstocked_units)::integer as total_excess_units, + ROUND(SUM(overstocked_cost)::numeric, 3) as total_excess_cost, + ROUND(SUM(overstocked_retail)::numeric, 3) as total_excess_retail + FROM product_metrics + WHERE status = 'Overstock' + `); + + // Get category breakdowns separately + const { rows: categoryData } = await executeQuery(` + SELECT + c.name as category_name, + COUNT(DISTINCT pm.pid)::integer as overstocked_products, + SUM(pm.overstocked_units)::integer as total_excess_units, + ROUND(SUM(pm.overstocked_cost)::numeric, 3) as total_excess_cost, + ROUND(SUM(pm.overstocked_retail)::numeric, 3) as total_excess_retail + FROM categories c + JOIN product_categories pc ON c.cat_id = pc.cat_id + JOIN product_metrics pm ON pc.pid = pm.pid + WHERE pm.status = 'Overstock' + GROUP BY c.name + ORDER BY total_excess_cost DESC + LIMIT 8 + `); - // Generate dummy data if the query returned empty results - if (rows[0].total_overstocked === null || rows[0].total_excess_units === null) { - console.log('Empty overstock metrics results, returning dummy data'); - return res.json({ - overstockedProducts: 10, - total_excess_units: 500, - total_excess_cost: 5000, - total_excess_retail: 10000, - category_data: [ - { category: "Electronics", products: 3, units: 150, cost: 1500, retail: 3000 }, - { category: "Clothing", products: 4, units: 200, cost: 2000, retail: 4000 }, - { category: "Home Goods", products: 2, units: 100, cost: 1000, retail: 2000 }, - { category: "Office Supplies", products: 1, units: 50, cost: 500, retail: 1000 } - ] - }); - } + console.log('Summary metrics:', summaryMetrics); + console.log('Category data count:', categoryData.length); // Format response with explicit type conversion const response = { - overstockedProducts: parseInt(rows[0].total_overstocked) || 0, - total_excess_units: parseInt(rows[0].total_excess_units) || 0, - total_excess_cost: parseFloat(rows[0].total_excess_cost) || 0, - total_excess_retail: parseFloat(rows[0].total_excess_retail) || 0, - category_data: rows[0].category_data || [] + overstockedProducts: parseInt(summaryMetrics.total_overstocked) || 0, + total_excess_units: parseInt(summaryMetrics.total_excess_units) || 0, + total_excess_cost: parseFloat(summaryMetrics.total_excess_cost) || 0, + total_excess_retail: parseFloat(summaryMetrics.total_excess_retail) || 0, + category_data: categoryData.map(cat => ({ + category: cat.category_name, + products: parseInt(cat.overstocked_products) || 0, + units: parseInt(cat.total_excess_units) || 0, + cost: parseFloat(cat.total_excess_cost) || 0, + retail: parseFloat(cat.total_excess_retail) || 0 + })) }; res.json(response); @@ -629,27 +572,26 @@ router.get('/overstock/products', async (req, res) => { try { const { rows } = await executeQuery(` SELECT - p.pid, - p.SKU, - p.title, - p.brand, - p.vendor, - p.stock_quantity, - p.cost_price, - p.price, - pm.daily_sales_avg, - pm.days_of_inventory, - pm.overstocked_amt, - (pm.overstocked_amt * p.cost_price) as excess_cost, - (pm.overstocked_amt * p.price) as excess_retail, + pm.pid, + pm.sku AS SKU, + pm.title, + pm.brand, + pm.vendor, + pm.current_stock as stock_quantity, + pm.current_cost_price as cost_price, + pm.current_price as price, + pm.sales_velocity_daily as daily_sales_avg, + pm.stock_cover_in_days as days_of_inventory, + pm.overstocked_units, + pm.overstocked_cost as excess_cost, + pm.overstocked_retail as excess_retail, STRING_AGG(c.name, ', ') as categories - FROM products p - JOIN product_metrics pm ON p.pid = pm.pid - LEFT JOIN product_categories pc ON p.pid = pc.pid + FROM product_metrics pm + LEFT JOIN product_categories pc ON pm.pid = pc.pid LEFT JOIN categories c ON pc.cat_id = c.cat_id - WHERE pm.stock_status = 'Overstocked' - GROUP BY p.pid, p.SKU, p.title, p.brand, p.vendor, p.stock_quantity, p.cost_price, p.price, - pm.daily_sales_avg, pm.days_of_inventory, pm.overstocked_amt + WHERE pm.status = 'Overstock' + GROUP BY pm.pid, pm.sku, pm.title, pm.brand, pm.vendor, pm.current_stock, pm.current_cost_price, pm.current_price, + pm.sales_velocity_daily, pm.stock_cover_in_days, pm.overstocked_units, pm.overstocked_cost, pm.overstocked_retail ORDER BY excess_cost DESC LIMIT $1 `, [limit]); @@ -827,42 +769,38 @@ router.get('/sales/metrics', async (req, res) => { const endDate = req.query.endDate || today.toISOString(); try { - // Get daily sales data + // Get daily orders and totals for the specified period const { rows: dailyRows } = await executeQuery(` SELECT - DATE(o.date) as sale_date, - COUNT(DISTINCT o.order_number) as total_orders, - SUM(o.quantity) as total_units, - SUM(o.price * o.quantity) as total_revenue, - SUM(p.cost_price * o.quantity) as total_cogs, - SUM((o.price - p.cost_price) * o.quantity) as total_profit - FROM orders o - JOIN products p ON o.pid = p.pid - WHERE o.canceled = false - AND o.date BETWEEN $1 AND $2 - GROUP BY DATE(o.date) + DATE(date) as sale_date, + COUNT(DISTINCT order_number) as total_orders, + SUM(quantity) as total_units, + SUM(price * quantity) as total_revenue, + SUM(costeach * quantity) as total_cogs + FROM orders + WHERE date BETWEEN $1 AND $2 + AND canceled = false + GROUP BY DATE(date) ORDER BY sale_date `, [startDate, endDate]); - // Get summary metrics - const { rows: metrics } = await executeQuery(` + // Get overall metrics for the period + const { rows: [metrics] } = await executeQuery(` SELECT - COUNT(DISTINCT o.order_number) as total_orders, - SUM(o.quantity) as total_units, - SUM(o.price * o.quantity) as total_revenue, - SUM(p.cost_price * o.quantity) as total_cogs, - SUM((o.price - p.cost_price) * o.quantity) as total_profit - FROM orders o - JOIN products p ON o.pid = p.pid - WHERE o.canceled = false - AND o.date BETWEEN $1 AND $2 + COUNT(DISTINCT order_number) as total_orders, + SUM(quantity) as total_units, + SUM(price * quantity) as total_revenue, + SUM(costeach * quantity) as total_cogs + FROM orders + WHERE date BETWEEN $1 AND $2 + AND canceled = false `, [startDate, endDate]); const response = { - totalOrders: parseInt(metrics[0]?.total_orders) || 0, - totalUnitsSold: parseInt(metrics[0]?.total_units) || 0, - totalCogs: parseFloat(metrics[0]?.total_cogs) || 0, - totalRevenue: parseFloat(metrics[0]?.total_revenue) || 0, + totalOrders: parseInt(metrics?.total_orders) || 0, + totalUnitsSold: parseInt(metrics?.total_units) || 0, + totalCogs: parseFloat(metrics?.total_cogs) || 0, + totalRevenue: parseFloat(metrics?.total_revenue) || 0, dailySales: dailyRows.map(day => ({ date: day.sale_date, units: parseInt(day.total_units) || 0, @@ -1304,39 +1242,33 @@ router.get('/inventory-health', async (req, res) => { }); // GET /dashboard/replenish/products -// Returns top products that need replenishment +// Returns list of products to replenish router.get('/replenish/products', async (req, res) => { - const limit = Math.max(1, Math.min(100, parseInt(req.query.limit) || 50)); + const limit = parseInt(req.query.limit) || 50; try { - const { rows: products } = await executeQuery(` + const { rows } = await executeQuery(` SELECT - p.pid, - p.SKU as sku, - p.title, - p.stock_quantity, - pm.daily_sales_avg, - pm.reorder_qty, - pm.last_purchase_date - FROM products p - JOIN product_metrics pm ON p.pid = pm.pid - WHERE p.replenishable = true - AND pm.stock_status IN ('Critical', 'Reorder') - AND pm.reorder_qty > 0 + pm.pid, + pm.sku, + pm.title, + pm.current_stock AS stock_quantity, + pm.sales_velocity_daily AS daily_sales_avg, + pm.replenishment_units AS reorder_qty, + pm.date_last_received AS last_purchase_date + FROM product_metrics pm + WHERE pm.is_replenishable = true + AND (pm.status IN ('Critical', 'Reorder') + OR pm.current_stock < 0) + AND pm.replenishment_units > 0 ORDER BY - CASE pm.stock_status + CASE pm.status WHEN 'Critical' THEN 1 WHEN 'Reorder' THEN 2 END, - pm.reorder_qty * p.cost_price DESC + pm.replenishment_cost DESC LIMIT $1 `, [limit]); - - res.json(products.map(p => ({ - ...p, - stock_quantity: parseInt(p.stock_quantity) || 0, - daily_sales_avg: parseFloat(p.daily_sales_avg) || 0, - reorder_qty: parseInt(p.reorder_qty) || 0 - }))); + res.json(rows); } catch (err) { console.error('Error fetching products to replenish:', err); res.status(500).json({ error: 'Failed to fetch products to replenish' }); diff --git a/inventory/src/components/analytics/CategoryPerformance.tsx b/inventory/src/components/analytics/CategoryPerformance.tsx index 01e502c..a8515a2 100644 --- a/inventory/src/components/analytics/CategoryPerformance.tsx +++ b/inventory/src/components/analytics/CategoryPerformance.tsx @@ -38,21 +38,22 @@ export function CategoryPerformance() { const rawData = await response.json(); return { performance: rawData.performance.map((item: any) => ({ - ...item, - categoryPath: item.categoryPath || item.category, + category: item.category || '', + categoryPath: item.categoryPath || item.categorypath || item.category || '', revenue: Number(item.revenue) || 0, profit: Number(item.profit) || 0, growth: Number(item.growth) || 0, - productCount: Number(item.productCount) || 0 + productCount: Number(item.productCount) || Number(item.productcount) || 0 })), distribution: rawData.distribution.map((item: any) => ({ - ...item, - categoryPath: item.categoryPath || item.category, + category: item.category || '', + categoryPath: item.categoryPath || item.categorypath || item.category || '', value: Number(item.value) || 0 })), trends: rawData.trends.map((item: any) => ({ - ...item, - categoryPath: item.categoryPath || item.category, + category: item.category || '', + categoryPath: item.categoryPath || item.categorypath || item.category || '', + month: item.month || '', sales: Number(item.sales) || 0 })) }; diff --git a/inventory/src/components/analytics/PriceAnalysis.tsx b/inventory/src/components/analytics/PriceAnalysis.tsx index 1626272..aa646b5 100644 --- a/inventory/src/components/analytics/PriceAnalysis.tsx +++ b/inventory/src/components/analytics/PriceAnalysis.tsx @@ -25,41 +25,91 @@ interface PriceData { } export function PriceAnalysis() { - const { data, isLoading } = useQuery({ + const { data, isLoading, error } = useQuery({ queryKey: ['price-analysis'], queryFn: async () => { - const response = await fetch(`${config.apiUrl}/analytics/pricing`); - if (!response.ok) { - throw new Error('Failed to fetch price analysis'); + try { + const response = await fetch(`${config.apiUrl}/analytics/pricing`); + if (!response.ok) { + throw new Error(`Failed to fetch: ${response.status}`); + } + const rawData = await response.json(); + + if (!rawData || !rawData.pricePoints) { + return { + pricePoints: [], + elasticity: [], + recommendations: [] + }; + } + + return { + pricePoints: (rawData.pricePoints || []).map((item: any) => ({ + price: Number(item.price) || 0, + salesVolume: Number(item.salesVolume || item.salesvolume) || 0, + revenue: Number(item.revenue) || 0, + category: item.category || '' + })), + elasticity: (rawData.elasticity || []).map((item: any) => ({ + date: item.date || '', + price: Number(item.price) || 0, + demand: Number(item.demand) || 0 + })), + recommendations: (rawData.recommendations || []).map((item: any) => ({ + product: item.product || '', + currentPrice: Number(item.currentPrice || item.currentprice) || 0, + recommendedPrice: Number(item.recommendedPrice || item.recommendedprice) || 0, + potentialRevenue: Number(item.potentialRevenue || item.potentialrevenue) || 0, + confidence: Number(item.confidence) || 0 + })) + }; + } catch (err) { + console.error('Error fetching price data:', err); + throw err; } - const rawData = await response.json(); - return { - pricePoints: rawData.pricePoints.map((item: any) => ({ - ...item, - price: Number(item.price) || 0, - salesVolume: Number(item.salesVolume) || 0, - revenue: Number(item.revenue) || 0 - })), - elasticity: rawData.elasticity.map((item: any) => ({ - ...item, - price: Number(item.price) || 0, - demand: Number(item.demand) || 0 - })), - recommendations: rawData.recommendations.map((item: any) => ({ - ...item, - currentPrice: Number(item.currentPrice) || 0, - recommendedPrice: Number(item.recommendedPrice) || 0, - potentialRevenue: Number(item.potentialRevenue) || 0, - confidence: Number(item.confidence) || 0 - })) - }; }, + retry: 1 }); - if (isLoading || !data) { + if (isLoading) { return
Loading price analysis...
; } + if (error || !data) { + return ( + + + Price Analysis + + +

+ Unable to load price analysis. The price metrics may need to be set up in the database. +

+
+
+ ); + } + + // Early return if no data to display + if ( + data.pricePoints.length === 0 && + data.elasticity.length === 0 && + data.recommendations.length === 0 + ) { + return ( + + + Price Analysis + + +

+ No price data available. This may be because the price metrics haven't been calculated yet. +

+
+
+ ); + } + return (
diff --git a/inventory/src/components/analytics/ProfitAnalysis.tsx b/inventory/src/components/analytics/ProfitAnalysis.tsx index e8710ac..ae54da1 100644 --- a/inventory/src/components/analytics/ProfitAnalysis.tsx +++ b/inventory/src/components/analytics/ProfitAnalysis.tsx @@ -38,22 +38,23 @@ export function ProfitAnalysis() { const rawData = await response.json(); return { byCategory: rawData.byCategory.map((item: any) => ({ - ...item, - categoryPath: item.categoryPath || item.category, - profitMargin: Number(item.profitMargin) || 0, + category: item.category || '', + categoryPath: item.categorypath || item.category || '', + profitMargin: item.profitmargin !== null ? Number(item.profitmargin) : 0, revenue: Number(item.revenue) || 0, cost: Number(item.cost) || 0 })), overTime: rawData.overTime.map((item: any) => ({ - ...item, - profitMargin: Number(item.profitMargin) || 0, + date: item.date || '', + profitMargin: item.profitmargin !== null ? Number(item.profitmargin) : 0, revenue: Number(item.revenue) || 0, cost: Number(item.cost) || 0 })), topProducts: rawData.topProducts.map((item: any) => ({ - ...item, - categoryPath: item.categoryPath || item.category, - profitMargin: Number(item.profitMargin) || 0, + product: item.product || '', + category: item.category || '', + categoryPath: item.categorypath || item.category || '', + profitMargin: item.profitmargin !== null ? Number(item.profitmargin) : 0, revenue: Number(item.revenue) || 0, cost: Number(item.cost) || 0 })) diff --git a/inventory/src/components/analytics/StockAnalysis.tsx b/inventory/src/components/analytics/StockAnalysis.tsx index fb12669..74c2756 100644 --- a/inventory/src/components/analytics/StockAnalysis.tsx +++ b/inventory/src/components/analytics/StockAnalysis.tsx @@ -28,42 +28,93 @@ interface StockData { } export function StockAnalysis() { - const { data, isLoading } = useQuery({ + const { data, isLoading, error } = useQuery({ queryKey: ['stock-analysis'], queryFn: async () => { - const response = await fetch(`${config.apiUrl}/analytics/stock`); - if (!response.ok) { - throw new Error('Failed to fetch stock analysis'); + try { + const response = await fetch(`${config.apiUrl}/analytics/stock`); + if (!response.ok) { + throw new Error(`Failed to fetch: ${response.status}`); + } + const rawData = await response.json(); + + if (!rawData || !rawData.turnoverByCategory) { + return { + turnoverByCategory: [], + stockLevels: [], + criticalItems: [] + }; + } + + return { + turnoverByCategory: (rawData.turnoverByCategory || []).map((item: any) => ({ + category: item.category || '', + turnoverRate: Number(item.turnoverRate || item.turnoverrate) || 0, + averageStock: Number(item.averageStock || item.averagestock) || 0, + totalSales: Number(item.totalSales || item.totalsales) || 0 + })), + stockLevels: (rawData.stockLevels || []).map((item: any) => ({ + date: item.date || '', + inStock: Number(item.inStock || item.instock) || 0, + lowStock: Number(item.lowStock || item.lowstock) || 0, + outOfStock: Number(item.outOfStock || item.outofstock) || 0 + })), + criticalItems: (rawData.criticalItems || []).map((item: any) => ({ + product: item.product || '', + sku: item.sku || '', + stockQuantity: Number(item.stockQuantity || item.stockquantity) || 0, + reorderPoint: Number(item.reorderPoint || item.reorderpoint) || 0, + turnoverRate: Number(item.turnoverRate || item.turnoverrate) || 0, + daysUntilStockout: Number(item.daysUntilStockout || item.daysuntilstockout) || 0 + })) + }; + } catch (err) { + console.error('Error fetching stock data:', err); + throw err; } - const rawData = await response.json(); - return { - turnoverByCategory: rawData.turnoverByCategory.map((item: any) => ({ - ...item, - turnoverRate: Number(item.turnoverRate) || 0, - averageStock: Number(item.averageStock) || 0, - totalSales: Number(item.totalSales) || 0 - })), - stockLevels: rawData.stockLevels.map((item: any) => ({ - ...item, - inStock: Number(item.inStock) || 0, - lowStock: Number(item.lowStock) || 0, - outOfStock: Number(item.outOfStock) || 0 - })), - criticalItems: rawData.criticalItems.map((item: any) => ({ - ...item, - stockQuantity: Number(item.stockQuantity) || 0, - reorderPoint: Number(item.reorderPoint) || 0, - turnoverRate: Number(item.turnoverRate) || 0, - daysUntilStockout: Number(item.daysUntilStockout) || 0 - })) - }; }, + retry: 1 }); - if (isLoading || !data) { + if (isLoading) { return
Loading stock analysis...
; } + if (error || !data) { + return ( + + + Stock Analysis + + +

+ Unable to load stock analysis. The stock metrics may need to be set up in the database. +

+
+
+ ); + } + + // Early return if no data to display + if ( + data.turnoverByCategory.length === 0 && + data.stockLevels.length === 0 && + data.criticalItems.length === 0 + ) { + return ( + + + Stock Analysis + + +

+ No stock data available. This may be because the stock metrics haven't been calculated yet. +

+
+
+ ); + } + const getStockStatus = (daysUntilStockout: number) => { if (daysUntilStockout <= 7) { return Critical; diff --git a/inventory/src/components/analytics/VendorPerformance.tsx b/inventory/src/components/analytics/VendorPerformance.tsx index 0bda368..e4ce60f 100644 --- a/inventory/src/components/analytics/VendorPerformance.tsx +++ b/inventory/src/components/analytics/VendorPerformance.tsx @@ -58,22 +58,22 @@ export function VendorPerformance() { // Create a complete structure even if some parts are missing const data: VendorData = { performance: rawData.performance.map((vendor: any) => ({ - vendor: vendor.vendor, - salesVolume: Number(vendor.salesVolume) || 0, - profitMargin: Number(vendor.profitMargin) || 0, - stockTurnover: Number(vendor.stockTurnover) || 0, + vendor: vendor.vendor || '', + salesVolume: vendor.salesVolume !== null ? Number(vendor.salesVolume) : 0, + profitMargin: vendor.profitMargin !== null ? Number(vendor.profitMargin) : 0, + stockTurnover: vendor.stockTurnover !== null ? Number(vendor.stockTurnover) : 0, productCount: Number(vendor.productCount) || 0, - growth: Number(vendor.growth) || 0 + growth: vendor.growth !== null ? Number(vendor.growth) : 0 })), comparison: rawData.comparison?.map((vendor: any) => ({ - vendor: vendor.vendor, - salesPerProduct: Number(vendor.salesPerProduct) || 0, - averageMargin: Number(vendor.averageMargin) || 0, + vendor: vendor.vendor || '', + salesPerProduct: vendor.salesPerProduct !== null ? Number(vendor.salesPerProduct) : 0, + averageMargin: vendor.averageMargin !== null ? Number(vendor.averageMargin) : 0, size: Number(vendor.size) || 0 })) || [], trends: rawData.trends?.map((vendor: any) => ({ - vendor: vendor.vendor, - month: vendor.month, + vendor: vendor.vendor || '', + month: vendor.month || '', sales: Number(vendor.sales) || 0 })) || [] };