From 9b2f9016f66ecc69f2ea12cb3d154e6e7bbe1e3c Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 7 Feb 2026 13:44:51 -0500 Subject: [PATCH] Redo analytics page --- .../metrics-new/update_periodic_metrics.sql | 30 +- inventory-server/src/routes/analytics.js | 1085 ++++++++--------- .../components/analytics/AgingSellThrough.tsx | 130 ++ .../analytics/CapitalEfficiency.tsx | 161 +++ .../analytics/CategoryPerformance.tsx | 202 --- .../components/analytics/DiscountImpact.tsx | 146 +++ .../components/analytics/GrowthMomentum.tsx | 160 +++ .../components/analytics/InventoryTrends.tsx | 127 ++ .../analytics/PortfolioAnalysis.tsx | 213 ++++ .../components/analytics/PriceAnalysis.tsx | 232 ---- .../components/analytics/ProfitAnalysis.tsx | 180 --- .../components/analytics/StockAnalysis.tsx | 227 ---- .../src/components/analytics/StockHealth.tsx | 244 ++++ .../src/components/analytics/StockoutRisk.tsx | 174 +++ .../analytics/VendorPerformance.tsx | 230 ---- inventory/src/pages/Analytics.tsx | 179 +-- inventory/src/utils/formatCurrency.ts | 5 + inventory/tsconfig.tsbuildinfo | 2 +- 18 files changed, 1984 insertions(+), 1743 deletions(-) create mode 100644 inventory/src/components/analytics/AgingSellThrough.tsx create mode 100644 inventory/src/components/analytics/CapitalEfficiency.tsx delete mode 100644 inventory/src/components/analytics/CategoryPerformance.tsx create mode 100644 inventory/src/components/analytics/DiscountImpact.tsx create mode 100644 inventory/src/components/analytics/GrowthMomentum.tsx create mode 100644 inventory/src/components/analytics/InventoryTrends.tsx create mode 100644 inventory/src/components/analytics/PortfolioAnalysis.tsx delete mode 100644 inventory/src/components/analytics/PriceAnalysis.tsx delete mode 100644 inventory/src/components/analytics/ProfitAnalysis.tsx delete mode 100644 inventory/src/components/analytics/StockAnalysis.tsx create mode 100644 inventory/src/components/analytics/StockHealth.tsx create mode 100644 inventory/src/components/analytics/StockoutRisk.tsx delete mode 100644 inventory/src/components/analytics/VendorPerformance.tsx create mode 100644 inventory/src/utils/formatCurrency.ts diff --git a/inventory-server/scripts/metrics-new/update_periodic_metrics.sql b/inventory-server/scripts/metrics-new/update_periodic_metrics.sql index a8c11de..4826cce 100644 --- a/inventory-server/scripts/metrics-new/update_periodic_metrics.sql +++ b/inventory-server/scripts/metrics-new/update_periodic_metrics.sql @@ -21,20 +21,30 @@ BEGIN RAISE NOTICE 'Running % module. Start Time: %', _module_name, _start_time; -- 1. Calculate Average Lead Time + -- For each completed PO, find the earliest receiving from the same supplier + -- within 180 days, then average those per-PO lead times per product. RAISE NOTICE 'Calculating Average Lead Time...'; - WITH LeadTimes AS ( + WITH po_first_receiving AS ( SELECT po.pid, - -- Calculate lead time by looking at when items ordered on POs were received - AVG(GREATEST(1, (r.received_date::date - po.date::date))) AS avg_days -- Use GREATEST(1,...) to avoid 0 or negative days + po.po_id, + po.date::date AS po_date, + MIN(r.received_date::date) AS first_receive_date FROM public.purchase_orders po - -- Join to receivings table to find actual receipts - JOIN public.receivings r ON r.pid = po.pid - WHERE po.status = 'done' -- Only include completed POs - AND r.received_date >= po.date -- Ensure received date is not before order date - -- Optional: add check to make sure receiving is related to PO if you have source_po_id - -- AND (r.source_po_id = po.po_id OR r.source_po_id IS NULL) - GROUP BY po.pid + JOIN public.receivings r + ON r.pid = po.pid + AND r.supplier_id = po.supplier_id -- same supplier + AND r.received_date >= po.date -- received after order + AND r.received_date <= po.date + INTERVAL '180 days' -- within reasonable window + WHERE po.status = 'done' + GROUP BY po.pid, po.po_id, po.date + ), + LeadTimes AS ( + SELECT + pid, + ROUND(AVG(GREATEST(1, first_receive_date - po_date))) AS avg_days + FROM po_first_receiving + GROUP BY pid ) UPDATE public.product_metrics pm SET avg_lead_time_days = lt.avg_days::int diff --git a/inventory-server/src/routes/analytics.js b/inventory-server/src/routes/analytics.js index c36ec62..a1afc52 100644 --- a/inventory-server/src/routes/analytics.js +++ b/inventory-server/src/routes/analytics.js @@ -25,7 +25,7 @@ router.get('/forecast', async (req, res) => { const sql = ` WITH params AS ( - SELECT + SELECT $1::date AS start_date, $2::date AS end_date, $3::text AS brand, @@ -44,7 +44,7 @@ router.get('/forecast', async (req, res) => { SELECT * FROM cp ), product_first_received AS ( - SELECT + SELECT p.pid, COALESCE(p.first_received::date, MIN(r.received_date)::date) AS first_received_date FROM products p @@ -80,13 +80,13 @@ router.get('/forecast', async (req, res) => { ) UNION ALL ( - SELECT + SELECT rp.pid, 'Uncategorized'::text AS category_name, 'Uncategorized'::text AS path FROM recent_products rp WHERE NOT EXISTS ( - SELECT 1 + SELECT 1 FROM product_categories pc JOIN categories c ON c.cat_id = pc.cat_id AND (c.type IS NULL OR c.type NOT IN (20,21)) LEFT JOIN category_path cp ON cp.cat_id = c.cat_id @@ -100,7 +100,7 @@ router.get('/forecast', async (req, res) => { ) ), product_sales AS ( - SELECT + SELECT p.pid, p.title, p.sku, @@ -110,12 +110,12 @@ router.get('/forecast', async (req, res) => { FROM recent_products rp JOIN products p ON p.pid = rp.pid LEFT JOIN params pr ON true - LEFT JOIN orders o ON o.pid = p.pid + LEFT JOIN orders o ON o.pid = p.pid AND o.date::date BETWEEN pr.start_date AND pr.end_date AND (o.canceled IS DISTINCT FROM TRUE) GROUP BY p.pid, p.title, p.sku, p.stock_quantity, p.price ) - SELECT + SELECT ppc.category_name, ppc.path, COUNT(ps.pid) AS num_products, @@ -144,7 +144,6 @@ router.get('/forecast', async (req, res) => { const { rows } = await pool.query(sql, [startISO, endISO, brand, titleSearch]); - // Normalize/shape response keys to match front-end expectations const shaped = rows.map(r => ({ category_name: r.category_name, path: r.path, @@ -164,611 +163,525 @@ router.get('/forecast', async (req, res) => { } }); -// Get overall analytics stats -router.get('/stats', async (req, res) => { +// ─── Inventory Intelligence Endpoints ──────────────────────────────────────── + +// Inventory KPI summary cards +router.get('/inventory-summary', async (req, res) => { try { const pool = req.app.locals.pool; - - const { rows: [results] } = await pool.query(` - 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 - ) + + const { rows: [summary] } = await pool.query(` 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 - const stats = { - profitMargin: Number(results.profitmargin) || 0, - averageMarkup: Number(results.averagemarkup) || 0, - stockTurnoverRate: Number(results.stockturnoverrate) || 0, - vendorCount: Number(results.vendorcount) || 0, - categoryCount: Number(results.categorycount) || 0, - averageOrderValue: Number(results.averageordervalue) || 0 - }; - - res.json(stats); - } catch (error) { - console.error('Error fetching analytics stats:', error); - res.status(500).json({ error: 'Failed to fetch analytics stats' }); - } -}); - -// Get profit analysis data -router.get('/profit', async (req, res) => { - try { - const pool = req.app.locals.pool; - - // Get profit margins by category with full path - const { rows: byCategory } = await pool.query(` - 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 - 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 over time - const { rows: overTime } = await pool.query(` - 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 by profit margin - const { rows: topProducts } = await pool.query(` - 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 - ), - 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 - 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 - `); - - res.json({ byCategory, overTime, topProducts }); - } catch (error) { - console.error('Error fetching profit analysis:', error); - res.status(500).json({ error: 'Failed to fetch profit analysis' }); - } -}); - -// Get vendor performance data -router.get('/vendors', async (req, res) => { - try { - const pool = req.app.locals.pool; - - // Set cache control headers to prevent 304 - res.set({ - 'Cache-Control': 'no-cache, no-store, must-revalidate', - 'Pragma': 'no-cache', - 'Expires': '0' - }); - - console.log('Fetching vendor performance data...'); - - // Get vendor performance metrics from the vendor_metrics table - const { rows: rawPerformance } = await pool.query(` - SELECT - 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 actual growth metrics from the vendor_metrics table - sales_growth_30d_vs_prev as growth - FROM vendor_metrics - WHERE revenue_30d > 0 - ORDER BY revenue_30d DESC - LIMIT 20 - `); - - // 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 - 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 - `); - - // Transform comparison data - const comparison = rawComparison.map(item => ({ - vendor: item.vendor, - salesPerProduct: Number(item.sales_per_product) || 0, - averageMargin: Number(item.average_margin) || 0, - size: Number(item.size) || 0 - })); - - console.log('Performance data ready. Sending response...'); - - // Return complete structure that the front-end expects - res.json({ - performance, - comparison, - // Add empty trends array to complete the structure - trends: [] - }); - } catch (error) { - console.error('Error fetching vendor performance:', error); - res.status(500).json({ error: 'Failed to fetch vendor performance data' }); - } -}); - -// Get stock analysis data -router.get('/stock', async (req, res) => { - try { - const pool = req.app.locals.pool; - console.log('Fetching stock analysis data...'); - - // Use the new metrics tables to get data - - // Get turnover by category - const { rows: turnoverByCategory } = await pool.query(` - 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 - 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 - 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 - `); - - // 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, + SUM(current_stock_cost) AS stock_investment, + SUM(on_order_cost) AS on_order_value, 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 + WHEN SUM(avg_stock_cost_30d) > 0 + THEN (SUM(cogs_30d) / SUM(avg_stock_cost_30d)) * 12 + ELSE 0 + END AS inventory_turns_annualized, 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 + WHEN SUM(avg_stock_cost_30d) > 0 + THEN SUM(profit_30d) / SUM(avg_stock_cost_30d) + ELSE 0 + END AS gmroi, + CASE + WHEN SUM(CASE WHEN sales_velocity_daily > 0 THEN 1 ELSE 0 END) > 0 + THEN SUM(CASE WHEN sales_velocity_daily > 0 THEN stock_cover_in_days ELSE 0 END) + / SUM(CASE WHEN sales_velocity_daily > 0 THEN 1 ELSE 0 END) + ELSE 0 + END AS avg_stock_cover_days, + COUNT(*) FILTER (WHERE current_stock > 0) AS products_in_stock, + COUNT(*) FILTER (WHERE is_old_stock = true) AS dead_stock_products, + SUM(CASE WHEN is_old_stock = true THEN current_stock_cost ELSE 0 END) AS dead_stock_value + FROM product_metrics + WHERE is_visible = true `); - + res.json({ - turnoverByCategory, - stockLevels, - criticalItems + stockInvestment: Number(summary.stock_investment) || 0, + onOrderValue: Number(summary.on_order_value) || 0, + inventoryTurns: Number(summary.inventory_turns_annualized) || 0, + gmroi: Number(summary.gmroi) || 0, + avgStockCoverDays: Number(summary.avg_stock_cover_days) || 0, + productsInStock: Number(summary.products_in_stock) || 0, + deadStockProducts: Number(summary.dead_stock_products) || 0, + deadStockValue: Number(summary.dead_stock_value) || 0, }); } catch (error) { - console.error('Error fetching stock analysis:', error); - res.status(500).json({ error: 'Failed to fetch stock analysis', details: error.message }); + console.error('Error fetching inventory summary:', error); + res.status(500).json({ error: 'Failed to fetch inventory summary' }); } }); -// Get price analysis data -router.get('/pricing', async (req, res) => { +// Daily sales activity & stockouts over time +router.get('/inventory-trends', async (req, res) => { try { const pool = req.app.locals.pool; - - // Get price points analysis - const { rows: pricePoints } = await pool.query(` - SELECT - CAST(p.price AS DECIMAL(15,3)) as price, - CAST(SUM(o.quantity) AS DECIMAL(15,3)) as salesVolume, - CAST(SUM(o.price * o.quantity) AS DECIMAL(15,3)) as revenue, - c.name as category - 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 '30 days' - GROUP BY p.price, c.name - HAVING SUM(o.quantity) > 0 - ORDER BY revenue DESC - LIMIT 50 - `); + const period = parseInt(req.query.period) || 30; + const validPeriods = [30, 90, 365]; + const days = validPeriods.includes(period) ? period : 30; - // Get price elasticity data (price changes vs demand) - const { rows: elasticity } = await pool.query(` - SELECT - to_char(o.date, 'YYYY-MM-DD') as date, - CAST(AVG(o.price) AS DECIMAL(15,3)) as price, - CAST(SUM(o.quantity) AS DECIMAL(15,3)) as demand - FROM orders o - WHERE o.date >= CURRENT_DATE - INTERVAL '30 days' - GROUP BY to_char(o.date, 'YYYY-MM-DD') - ORDER BY date - `); + const { rows } = await pool.query(` + SELECT + snapshot_date AS date, + COUNT(*) FILTER (WHERE stockout_flag = true) AS stockout_count, + SUM(units_sold) AS units_sold + FROM daily_product_snapshots + WHERE snapshot_date >= CURRENT_DATE - make_interval(days => $1) + GROUP BY snapshot_date + ORDER BY snapshot_date + `, [days]); - // Get price optimization recommendations - const { rows: recommendations } = await pool.query(` - SELECT - p.title as product, - CAST(p.price AS DECIMAL(15,3)) as currentPrice, - CAST( - ROUND( - CASE - WHEN AVG(o.quantity) > 10 THEN p.price * 1.1 - WHEN AVG(o.quantity) < 2 THEN p.price * 0.9 - ELSE p.price - END, 2 - ) AS DECIMAL(15,3) - ) as recommendedPrice, - CAST( - ROUND( - SUM(o.price * o.quantity) * - CASE - WHEN AVG(o.quantity) > 10 THEN 1.15 - WHEN AVG(o.quantity) < 2 THEN 0.95 - ELSE 1 - END, 2 - ) AS DECIMAL(15,3) - ) as potentialRevenue, - CASE - WHEN AVG(o.quantity) > 10 THEN 85 - WHEN AVG(o.quantity) < 2 THEN 75 - ELSE 65 - END as confidence - FROM products p - LEFT JOIN orders o ON p.pid = o.pid - WHERE o.date >= CURRENT_DATE - INTERVAL '30 days' - GROUP BY p.pid, p.price, p.title - HAVING ABS( - CAST( - ROUND( - CASE - WHEN AVG(o.quantity) > 10 THEN p.price * 1.1 - WHEN AVG(o.quantity) < 2 THEN p.price * 0.9 - ELSE p.price - END, 2 - ) AS DECIMAL(15,3) - ) - CAST(p.price AS DECIMAL(15,3)) - ) > 0 - ORDER BY - CAST( - ROUND( - SUM(o.price * o.quantity) * - CASE - WHEN AVG(o.quantity) > 10 THEN 1.15 - WHEN AVG(o.quantity) < 2 THEN 0.95 - ELSE 1 - END, 2 - ) AS DECIMAL(15,3) - ) - CAST(SUM(o.price * o.quantity) AS DECIMAL(15,3)) DESC - LIMIT 10 - `); - - res.json({ pricePoints, elasticity, recommendations }); + res.json(rows.map(r => ({ + date: r.date, + stockoutCount: Number(r.stockout_count) || 0, + unitsSold: Number(r.units_sold) || 0, + }))); } catch (error) { - console.error('Error fetching price analysis:', error); - res.status(500).json({ error: 'Failed to fetch price analysis' }); + console.error('Error fetching inventory trends:', error); + res.status(500).json({ error: 'Failed to fetch inventory trends' }); } }); -// Get category performance data -router.get('/categories', async (req, res) => { +// ABC Portfolio analysis +router.get('/portfolio', async (req, res) => { try { const pool = req.app.locals.pool; - - // Common CTE for category paths - const categoryPathCTE = ` - 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 - ) - `; - - // Get category performance metrics with full path - const { rows: performance } = await pool.query(` - ${categoryPathCTE}, - monthly_sales AS ( - SELECT - c.name, - cp.path, - SUM(CASE - WHEN o.date >= CURRENT_DATE - INTERVAL '30 days' - THEN o.price * o.quantity - ELSE 0 - END) as current_month, - 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) as previous_month - 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 '60 days' - GROUP BY c.name, cp.path - ) - SELECT - c.name as category, - cp.path as categoryPath, - SUM(o.price * o.quantity) as revenue, - SUM(o.price * o.quantity - p.cost_price * o.quantity) as profit, - ROUND( - ((ms.current_month / NULLIF(ms.previous_month, 0)) - 1) * 100, - 1 - ) as growth, - COUNT(DISTINCT p.pid) as productCount - 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 - LEFT JOIN monthly_sales ms ON c.name = ms.name AND cp.path = ms.path - WHERE o.date >= CURRENT_DATE - INTERVAL '60 days' - GROUP BY c.name, cp.path, ms.current_month, ms.previous_month - HAVING SUM(o.price * o.quantity) > 0 - ORDER BY revenue DESC - LIMIT 10 + + // ABC class breakdown + const { rows: abcBreakdown } = await pool.query(` + SELECT + COALESCE(abc_class, 'N/A') AS abc_class, + COUNT(*) AS product_count, + SUM(revenue_30d) AS revenue, + SUM(current_stock_cost) AS stock_cost, + SUM(profit_30d) AS profit, + SUM(sales_30d) AS units_sold + FROM product_metrics + WHERE is_visible = true + GROUP BY abc_class + ORDER BY abc_class `); - // Get category revenue distribution with full path - const { rows: distribution } = await pool.query(` - ${categoryPathCTE} - SELECT - c.name as category, - cp.path as categoryPath, - SUM(o.price * o.quantity) as value - 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 - HAVING SUM(o.price * o.quantity) > 0 - ORDER BY value DESC - LIMIT 6 + // Dead stock and overstock summary + const { rows: [stockIssues] } = await pool.query(` + SELECT + COUNT(*) FILTER (WHERE is_old_stock = true) AS dead_stock_count, + SUM(CASE WHEN is_old_stock = true THEN current_stock_cost ELSE 0 END) AS dead_stock_cost, + SUM(CASE WHEN is_old_stock = true THEN current_stock_retail ELSE 0 END) AS dead_stock_retail, + COUNT(*) FILTER (WHERE overstocked_units > 0) AS overstock_count, + SUM(COALESCE(overstocked_cost, 0)) AS overstock_cost, + SUM(COALESCE(overstocked_retail, 0)) AS overstock_retail + FROM product_metrics + WHERE is_visible = true `); - // Get category sales trends with full path - const { rows: trends } = await pool.query(` - ${categoryPathCTE} - SELECT - c.name as category, - cp.path as categoryPath, - to_char(o.date, 'Mon YYYY') as month, - SUM(o.price * o.quantity) as sales - 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 '6 months' - GROUP BY - c.name, - cp.path, - to_char(o.date, 'Mon YYYY'), - to_char(o.date, 'YYYY-MM') - ORDER BY - c.name, - to_char(o.date, 'YYYY-MM') - `); - - res.json({ performance, distribution, trends }); + res.json({ + abcBreakdown: abcBreakdown.map(r => ({ + abcClass: r.abc_class, + productCount: Number(r.product_count) || 0, + revenue: Number(r.revenue) || 0, + stockCost: Number(r.stock_cost) || 0, + profit: Number(r.profit) || 0, + unitsSold: Number(r.units_sold) || 0, + })), + stockIssues: { + deadStockCount: Number(stockIssues.dead_stock_count) || 0, + deadStockCost: Number(stockIssues.dead_stock_cost) || 0, + deadStockRetail: Number(stockIssues.dead_stock_retail) || 0, + overstockCount: Number(stockIssues.overstock_count) || 0, + overstockCost: Number(stockIssues.overstock_cost) || 0, + overstockRetail: Number(stockIssues.overstock_retail) || 0, + }, + }); } catch (error) { - console.error('Error fetching category performance:', error); - res.status(500).json({ error: 'Failed to fetch category performance' }); + console.error('Error fetching portfolio analysis:', error); + res.status(500).json({ error: 'Failed to fetch portfolio analysis' }); } }); -module.exports = router; +// Capital efficiency — GMROI by vendor (single combined query) +router.get('/efficiency', async (req, res) => { + try { + const pool = req.app.locals.pool; + + const { rows } = await pool.query(` + SELECT + vendor AS vendor_name, + COUNT(*) AS product_count, + SUM(current_stock_cost) AS stock_cost, + SUM(profit_30d) AS profit_30d, + SUM(revenue_30d) AS revenue_30d, + CASE + WHEN SUM(avg_stock_cost_30d) > 0 + THEN SUM(profit_30d) / SUM(avg_stock_cost_30d) + ELSE 0 + END AS gmroi + FROM product_metrics + WHERE is_visible = true + AND vendor IS NOT NULL + AND current_stock_cost > 0 + GROUP BY vendor + HAVING SUM(current_stock_cost) > 100 + ORDER BY SUM(current_stock_cost) DESC + LIMIT 30 + `); + + res.json({ + vendors: rows.map(r => ({ + vendor: r.vendor_name, + productCount: Number(r.product_count) || 0, + stockCost: Number(r.stock_cost) || 0, + profit30d: Number(r.profit_30d) || 0, + revenue30d: Number(r.revenue_30d) || 0, + gmroi: Number(r.gmroi) || 0, + })), + }); + } catch (error) { + console.error('Error fetching capital efficiency:', error); + res.status(500).json({ error: 'Failed to fetch capital efficiency' }); + } +}); + +// Demand & stock health +router.get('/stock-health', async (req, res) => { + try { + const pool = req.app.locals.pool; + + // Stock cover distribution (histogram buckets) + const { rows: coverDistribution } = await pool.query(` + SELECT + CASE + WHEN stock_cover_in_days IS NULL OR stock_cover_in_days <= 0 THEN '0 (Stockout)' + WHEN stock_cover_in_days <= 7 THEN '1-7 days' + WHEN stock_cover_in_days <= 14 THEN '8-14 days' + WHEN stock_cover_in_days <= 30 THEN '15-30 days' + WHEN stock_cover_in_days <= 60 THEN '31-60 days' + WHEN stock_cover_in_days <= 90 THEN '61-90 days' + WHEN stock_cover_in_days <= 180 THEN '91-180 days' + ELSE '180+ days' + END AS bucket, + CASE + WHEN stock_cover_in_days IS NULL OR stock_cover_in_days <= 0 THEN 0 + WHEN stock_cover_in_days <= 7 THEN 1 + WHEN stock_cover_in_days <= 14 THEN 2 + WHEN stock_cover_in_days <= 30 THEN 3 + WHEN stock_cover_in_days <= 60 THEN 4 + WHEN stock_cover_in_days <= 90 THEN 5 + WHEN stock_cover_in_days <= 180 THEN 6 + ELSE 7 + END AS sort_order, + COUNT(*) AS product_count, + SUM(current_stock_cost) AS stock_cost + FROM product_metrics + WHERE is_visible = true + AND is_replenishable = true + AND sales_30d > 0 + GROUP BY 1, 2 + ORDER BY sort_order + `); + + // Demand pattern distribution + const { rows: demandPatterns } = await pool.query(` + SELECT + COALESCE(demand_pattern, 'unknown') AS pattern, + COUNT(*) AS product_count, + SUM(revenue_30d) AS revenue, + SUM(current_stock_cost) AS stock_cost + FROM product_metrics + WHERE is_visible = true + AND sales_30d > 0 + GROUP BY demand_pattern + ORDER BY COUNT(*) DESC + `); + + // Service level / stockout summary + const { rows: [serviceStats] } = await pool.query(` + SELECT + ROUND(AVG(fill_rate_30d)::numeric, 1) AS avg_fill_rate, + ROUND(AVG(service_level_30d)::numeric, 1) AS avg_service_level, + SUM(stockout_incidents_30d) AS total_stockout_incidents, + SUM(lost_sales_incidents_30d) AS total_lost_sales_incidents, + SUM(forecast_lost_sales_units) AS total_lost_units, + SUM(forecast_lost_revenue) AS total_lost_revenue, + COUNT(*) FILTER (WHERE stockout_days_30d > 0) AS products_with_stockouts, + ROUND(AVG(stockout_rate_30d)::numeric, 1) AS avg_stockout_rate + FROM product_metrics + WHERE is_visible = true + AND is_replenishable = true + AND sales_30d > 0 + `); + + res.json({ + coverDistribution: coverDistribution.map(r => ({ + bucket: r.bucket, + productCount: Number(r.product_count) || 0, + stockCost: Number(r.stock_cost) || 0, + })), + demandPatterns: demandPatterns.map(r => ({ + pattern: r.pattern, + productCount: Number(r.product_count) || 0, + revenue: Number(r.revenue) || 0, + stockCost: Number(r.stock_cost) || 0, + })), + serviceStats: { + avgFillRate: Number(serviceStats.avg_fill_rate) || 0, + avgServiceLevel: Number(serviceStats.avg_service_level) || 0, + totalStockoutIncidents: Number(serviceStats.total_stockout_incidents) || 0, + totalLostSalesIncidents: Number(serviceStats.total_lost_sales_incidents) || 0, + totalLostUnits: Number(serviceStats.total_lost_units) || 0, + totalLostRevenue: Number(serviceStats.total_lost_revenue) || 0, + productsWithStockouts: Number(serviceStats.products_with_stockouts) || 0, + avgStockoutRate: Number(serviceStats.avg_stockout_rate) || 0, + }, + }); + } catch (error) { + console.error('Error fetching stock health:', error); + res.status(500).json({ error: 'Failed to fetch stock health' }); + } +}); + +// Aging & sell-through by age cohort +router.get('/aging', async (req, res) => { + try { + const pool = req.app.locals.pool; + + const { rows } = await pool.query(` + SELECT + CASE + WHEN age_days <= 30 THEN '0-30d' + WHEN age_days <= 60 THEN '31-60d' + WHEN age_days <= 90 THEN '61-90d' + WHEN age_days <= 180 THEN '91-180d' + WHEN age_days <= 365 THEN '181-365d' + ELSE '365d+' + END AS cohort, + CASE + WHEN age_days <= 30 THEN 0 + WHEN age_days <= 60 THEN 1 + WHEN age_days <= 90 THEN 2 + WHEN age_days <= 180 THEN 3 + WHEN age_days <= 365 THEN 4 + ELSE 5 + END AS sort_order, + COUNT(*) AS product_count, + ROUND(AVG( + CASE WHEN avg_stock_units_30d > 0 + THEN (sales_30d::numeric / avg_stock_units_30d) * 100 + END + )::numeric, 1) AS avg_sell_through, + SUM(current_stock_cost) AS stock_cost, + SUM(revenue_30d) AS revenue, + SUM(sales_30d) AS units_sold + FROM product_metrics + WHERE is_visible = true + AND current_stock > 0 + AND age_days IS NOT NULL + GROUP BY 1, 2 + ORDER BY sort_order + `); + + res.json(rows.map(r => ({ + cohort: r.cohort, + productCount: Number(r.product_count) || 0, + avgSellThrough: Number(r.avg_sell_through) || 0, + stockCost: Number(r.stock_cost) || 0, + revenue: Number(r.revenue) || 0, + unitsSold: Number(r.units_sold) || 0, + }))); + } catch (error) { + console.error('Error fetching aging data:', error); + res.status(500).json({ error: 'Failed to fetch aging data' }); + } +}); + +// Reorder risk — lead time vs sells_out_in_days +router.get('/stockout-risk', async (req, res) => { + try { + const pool = req.app.locals.pool; + + // CTE shared by chart data and summary + const leadTimeSql = ` + LEAST(180, CASE + WHEN avg_lead_time_days IS NOT NULL AND avg_lead_time_days > 0 THEN avg_lead_time_days + WHEN config_lead_time IS NOT NULL AND config_lead_time > 0 THEN config_lead_time + ELSE 14 + END)`; + + // Summary: accurate counts across ALL products (not just the chart sample) + const { rows: [summary] } = await pool.query(` + SELECT + COUNT(*) FILTER (WHERE sells_out_in_days <= ${leadTimeSql}) AS at_risk_count, + COUNT(*) FILTER (WHERE sells_out_in_days <= ${leadTimeSql} AND abc_class = 'A') AS critical_a_count, + COALESCE(SUM(revenue_30d) FILTER (WHERE sells_out_in_days <= ${leadTimeSql}), 0) AS at_risk_revenue + FROM product_metrics + WHERE is_visible = true + AND is_replenishable = true + AND sells_out_in_days IS NOT NULL + AND sells_out_in_days > 0 + AND sales_30d > 0 + `); + + // Sample products on both sides of the risk line so the diagonal is meaningful. + // 60 at-risk (buffer <= 0), 40 closest safe (buffer > 0). + const { rows } = await pool.query(` + WITH base AS ( + SELECT + title, sku, vendor, + ${leadTimeSql} AS lead_time_days, + sells_out_in_days, current_stock, sales_velocity_daily, + revenue_30d, abc_class + FROM product_metrics + WHERE is_visible = true + AND is_replenishable = true + AND sells_out_in_days IS NOT NULL + AND sells_out_in_days > 0 + AND sales_30d > 0 + ) + (SELECT * FROM base WHERE sells_out_in_days <= lead_time_days + ORDER BY (sells_out_in_days - lead_time_days) ASC LIMIT 60) + UNION ALL + (SELECT * FROM base WHERE sells_out_in_days > lead_time_days + ORDER BY (sells_out_in_days - lead_time_days) ASC LIMIT 40) + `); + + res.json({ + summary: { + atRiskCount: Number(summary.at_risk_count) || 0, + criticalACount: Number(summary.critical_a_count) || 0, + atRiskRevenue: Number(summary.at_risk_revenue) || 0, + }, + products: rows.map(r => ({ + title: r.title, + sku: r.sku, + vendor: r.vendor, + leadTimeDays: Number(r.lead_time_days) || 0, + sellsOutInDays: Number(r.sells_out_in_days) || 0, + currentStock: Number(r.current_stock) || 0, + velocityDaily: Number(r.sales_velocity_daily) || 0, + revenue30d: Number(r.revenue_30d) || 0, + abcClass: r.abc_class || 'N/A', + })), + }); + } catch (error) { + console.error('Error fetching stockout risk:', error); + res.status(500).json({ error: 'Failed to fetch stockout risk' }); + } +}); + +// Discount / markdown impact +router.get('/discounts', async (req, res) => { + try { + const pool = req.app.locals.pool; + + const { rows } = await pool.query(` + SELECT + COALESCE(abc_class, 'N/A') AS abc_class, + CASE + WHEN discount_rate_30d IS NULL OR discount_rate_30d <= 0 THEN 'No Discount' + WHEN discount_rate_30d <= 10 THEN '1-10%' + WHEN discount_rate_30d <= 20 THEN '11-20%' + WHEN discount_rate_30d <= 30 THEN '21-30%' + ELSE '30%+' + END AS discount_bucket, + CASE + WHEN discount_rate_30d IS NULL OR discount_rate_30d <= 0 THEN 0 + WHEN discount_rate_30d <= 10 THEN 1 + WHEN discount_rate_30d <= 20 THEN 2 + WHEN discount_rate_30d <= 30 THEN 3 + ELSE 4 + END AS sort_order, + COUNT(*) AS product_count, + ROUND(AVG(CASE WHEN avg_stock_units_30d > 0 THEN (sales_30d::numeric / avg_stock_units_30d) * 100 END)::numeric, 1) AS avg_sell_through, + SUM(revenue_30d) AS revenue, + SUM(discounts_30d) AS discount_amount, + SUM(profit_30d) AS profit + FROM product_metrics + WHERE is_visible = true + AND sales_30d > 0 + AND abc_class IS NOT NULL + GROUP BY 1, 2, 3 + ORDER BY abc_class, sort_order + `); + + res.json(rows.map(r => ({ + abcClass: r.abc_class, + discountBucket: r.discount_bucket, + productCount: Number(r.product_count) || 0, + avgSellThrough: Number(r.avg_sell_through) || 0, + revenue: Number(r.revenue) || 0, + discountAmount: Number(r.discount_amount) || 0, + profit: Number(r.profit) || 0, + }))); + } catch (error) { + console.error('Error fetching discount analysis:', error); + res.status(500).json({ error: 'Failed to fetch discount analysis' }); + } +}); + +// YoY growth momentum +router.get('/growth', async (req, res) => { + try { + const pool = req.app.locals.pool; + + const { rows } = await pool.query(` + SELECT + COALESCE(abc_class, 'N/A') AS abc_class, + CASE + WHEN sales_growth_yoy > 50 THEN 'Strong Growth (>50%)' + WHEN sales_growth_yoy > 0 THEN 'Growing (0-50%)' + WHEN sales_growth_yoy > -50 THEN 'Declining (0-50%)' + ELSE 'Sharp Decline (>50%)' + END AS growth_bucket, + CASE + WHEN sales_growth_yoy > 50 THEN 0 + WHEN sales_growth_yoy > 0 THEN 1 + WHEN sales_growth_yoy > -50 THEN 2 + ELSE 3 + END AS sort_order, + COUNT(*) AS product_count, + SUM(revenue_30d) AS revenue, + SUM(current_stock_cost) AS stock_cost + FROM product_metrics + WHERE is_visible = true + AND sales_growth_yoy IS NOT NULL + GROUP BY 1, 2, 3 + ORDER BY abc_class, sort_order + `); + + // Summary stats + const { rows: [summary] } = await pool.query(` + SELECT + COUNT(*) AS total_with_yoy, + COUNT(*) FILTER (WHERE sales_growth_yoy > 0) AS growing_count, + COUNT(*) FILTER (WHERE sales_growth_yoy <= 0) AS declining_count, + ROUND(AVG(sales_growth_yoy)::numeric, 1) AS avg_growth, + ROUND(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY sales_growth_yoy)::numeric, 1) AS median_growth + FROM product_metrics + WHERE is_visible = true + AND sales_growth_yoy IS NOT NULL + `); + + res.json({ + byClass: rows.map(r => ({ + abcClass: r.abc_class, + growthBucket: r.growth_bucket, + productCount: Number(r.product_count) || 0, + revenue: Number(r.revenue) || 0, + stockCost: Number(r.stock_cost) || 0, + })), + summary: { + totalWithYoy: Number(summary.total_with_yoy) || 0, + growingCount: Number(summary.growing_count) || 0, + decliningCount: Number(summary.declining_count) || 0, + avgGrowth: Number(summary.avg_growth) || 0, + medianGrowth: Number(summary.median_growth) || 0, + }, + }); + } catch (error) { + console.error('Error fetching growth data:', error); + res.status(500).json({ error: 'Failed to fetch growth data' }); + } +}); + +module.exports = router; diff --git a/inventory/src/components/analytics/AgingSellThrough.tsx b/inventory/src/components/analytics/AgingSellThrough.tsx new file mode 100644 index 0000000..4195349 --- /dev/null +++ b/inventory/src/components/analytics/AgingSellThrough.tsx @@ -0,0 +1,130 @@ +import { useQuery } from '@tanstack/react-query'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { + ResponsiveContainer, + BarChart, + Bar, + XAxis, + YAxis, + Tooltip, + CartesianGrid, + Cell, +} from 'recharts'; +import config from '../../config'; +import { METRIC_COLORS } from '@/lib/dashboard/designTokens'; +import { formatCurrency } from '@/utils/formatCurrency'; + +interface AgingCohort { + cohort: string; + productCount: number; + avgSellThrough: number; + stockCost: number; + revenue: number; + unitsSold: number; +} + +function getSellThroughColor(rate: number): string { + if (rate >= 30) return METRIC_COLORS.revenue; + if (rate >= 15) return METRIC_COLORS.orders; + if (rate >= 5) return METRIC_COLORS.comparison; + return '#ef4444'; +} + +export function AgingSellThrough() { + const { data, isLoading } = useQuery({ + queryKey: ['aging-sell-through'], + queryFn: async () => { + const response = await fetch(`${config.apiUrl}/analytics/aging`); + if (!response.ok) throw new Error('Failed to fetch aging data'); + return response.json(); + }, + }); + + if (isLoading || !data) { + return ( + + Aging & Sell-Through + +
+
Loading aging data...
+
+
+
+ ); + } + + return ( +
+ + + Sell-Through Rate by Age +

+ Avg 30-day sell-through % for products by age since first received +

+
+ + + + + + `${v}%`} tick={{ fontSize: 11 }} /> + { + if (!active || !payload?.length) return null; + const d = payload[0].payload as AgingCohort; + return ( +
+

Age: {d.cohort}

+

Sell-through: {d.avgSellThrough}%

+

{d.productCount.toLocaleString()} products

+

Stock value: {formatCurrency(d.stockCost)}

+
+ ); + }} + /> + + {data.map((entry, i) => ( + + ))} + +
+
+
+
+ + + + Capital Tied Up by Age +

+ Stock investment distribution across product age cohorts +

+
+ + + + + + + { + if (!active || !payload?.length) return null; + const d = payload[0].payload as AgingCohort; + return ( +
+

Age: {d.cohort}

+

Stock cost: {formatCurrency(d.stockCost)}

+

Revenue (30d): {formatCurrency(d.revenue)}

+

{d.productCount.toLocaleString()} products

+

{d.unitsSold.toLocaleString()} units sold (30d)

+
+ ); + }} + /> + +
+
+
+
+
+ ); +} diff --git a/inventory/src/components/analytics/CapitalEfficiency.tsx b/inventory/src/components/analytics/CapitalEfficiency.tsx new file mode 100644 index 0000000..6475048 --- /dev/null +++ b/inventory/src/components/analytics/CapitalEfficiency.tsx @@ -0,0 +1,161 @@ +import { useQuery } from '@tanstack/react-query'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { + ResponsiveContainer, + BarChart, + Bar, + XAxis, + YAxis, + Tooltip, + CartesianGrid, + ScatterChart, + Scatter, + ZAxis, + Cell, + ReferenceLine, +} from 'recharts'; +import config from '../../config'; +import { METRIC_COLORS } from '@/lib/dashboard/designTokens'; +import { formatCurrency } from '@/utils/formatCurrency'; + +interface VendorData { + vendor: string; + productCount: number; + stockCost: number; + profit30d: number; + revenue30d: number; + gmroi: number; +} + +interface EfficiencyData { + vendors: VendorData[]; +} + +function getGmroiColor(gmroi: number): string { + if (gmroi >= 1) return METRIC_COLORS.revenue; // emerald — good + if (gmroi >= 0.3) return METRIC_COLORS.comparison; // amber — ok + return '#ef4444'; // red — poor +} + +export function CapitalEfficiency() { + const { data, isLoading } = useQuery({ + queryKey: ['capital-efficiency'], + queryFn: async () => { + const response = await fetch(`${config.apiUrl}/analytics/efficiency`); + if (!response.ok) throw new Error('Failed to fetch capital efficiency'); + return response.json(); + }, + }); + + if (isLoading || !data) { + return ( + + Capital Efficiency + +
+
Loading efficiency...
+
+
+
+ ); + } + + // Top 15 by GMROI for bar chart + const sortedGmroi = [...data.vendors].sort((a, b) => b.gmroi - a.gmroi).slice(0, 15); + + return ( +
+ + + GMROI by Vendor +

+ Gross margin return on inventory investment (top vendors by stock value) +

+
+ + + + + + + { + if (!active || !payload?.length) return null; + const d = payload[0].payload as VendorData; + return ( +
+

{d.vendor}

+

GMROI: {d.gmroi.toFixed(2)}

+

Stock Investment: {formatCurrency(d.stockCost)}

+

Profit (30d): {formatCurrency(d.profit30d)}

+

Revenue (30d): {formatCurrency(d.revenue30d)}

+

{d.productCount} products

+
+ ); + }} + /> + + + {sortedGmroi.map((entry, i) => ( + + ))} + +
+
+
+
+ + + + Investment vs Profit by Vendor +

+ Bubble size = product count. Ideal: high profit, low stock cost. +

+
+ + + + + + + + { + if (!active || !payload?.length) return null; + const d = payload[0].payload as VendorData; + return ( +
+

{d.vendor}

+

Stock Investment: {formatCurrency(d.stockCost)}

+

Profit (30d): {formatCurrency(d.profit30d)}

+

Revenue (30d): {formatCurrency(d.revenue30d)}

+

{d.productCount} products

+
+ ); + }} + /> + +
+
+
+
+
+ ); +} diff --git a/inventory/src/components/analytics/CategoryPerformance.tsx b/inventory/src/components/analytics/CategoryPerformance.tsx deleted file mode 100644 index a8515a2..0000000 --- a/inventory/src/components/analytics/CategoryPerformance.tsx +++ /dev/null @@ -1,202 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip, PieChart, Pie, Cell, Legend } from 'recharts'; -import config from '../../config'; - -interface CategoryData { - performance: { - category: string; - categoryPath: string; // Full hierarchy path - revenue: number; - profit: number; - growth: number; - productCount: number; - }[]; - distribution: { - category: string; - categoryPath: string; // Full hierarchy path - value: number; - }[]; - trends: { - category: string; - categoryPath: string; // Full hierarchy path - month: string; - sales: number; - }[]; -} - -const COLORS = ['#4ade80', '#60a5fa', '#f87171', '#fbbf24', '#a78bfa', '#f472b6']; - -export function CategoryPerformance() { - const { data, isLoading } = useQuery({ - queryKey: ['category-performance'], - queryFn: async () => { - const response = await fetch(`${config.apiUrl}/analytics/categories`); - if (!response.ok) { - throw new Error('Failed to fetch category performance'); - } - const rawData = await response.json(); - return { - performance: rawData.performance.map((item: any) => ({ - 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) || Number(item.productcount) || 0 - })), - distribution: rawData.distribution.map((item: any) => ({ - category: item.category || '', - categoryPath: item.categoryPath || item.categorypath || item.category || '', - value: Number(item.value) || 0 - })), - trends: rawData.trends.map((item: any) => ({ - category: item.category || '', - categoryPath: item.categoryPath || item.categorypath || item.category || '', - month: item.month || '', - sales: Number(item.sales) || 0 - })) - }; - }, - }); - - if (isLoading || !data) { - return
Loading category performance...
; - } - - const formatGrowth = (growth: number) => { - const value = growth >= 0 ? `+${growth.toFixed(1)}%` : `${growth.toFixed(1)}%`; - const color = growth >= 0 ? 'text-green-500' : 'text-red-500'; - return {value}; - }; - - const getShortCategoryName = (path: string) => path.split(' > ').pop() || path; - - return ( -
-
- - - Category Revenue Distribution - - - - - getShortCategoryName(categoryPath)} - > - {data.distribution.map((entry, index) => ( - - ))} - - [ - `$${value.toLocaleString()}`, -
-
Category Path:
-
{props.payload.categoryPath}
-
Revenue
-
- ]} - /> - getShortCategoryName(value)} - wrapperStyle={{ fontSize: '12px' }} - /> -
-
-
-
- - - - Category Growth Rates - - - - - ( - - - {getShortCategoryName(payload.value)} - - - )} - /> - `${value}%`} /> - [ - `${value.toFixed(1)}%`, -
-
Category Path:
-
{props.payload.categoryPath}
-
Growth Rate
-
- ]} - /> - -
-
-
-
-
- - - - Category Performance Details - - -
- {data.performance.map((category) => ( -
-
-
-

{getShortCategoryName(category.categoryPath)}

-

{category.categoryPath}

-
-

- {category.productCount} products -

-
-
-

- ${category.revenue.toLocaleString()} revenue -

-

- ${category.profit.toLocaleString()} profit -

-

- Growth: {formatGrowth(category.growth)} -

-
-
- ))} -
-
-
-
- ); -} \ No newline at end of file diff --git a/inventory/src/components/analytics/DiscountImpact.tsx b/inventory/src/components/analytics/DiscountImpact.tsx new file mode 100644 index 0000000..9cb0bbe --- /dev/null +++ b/inventory/src/components/analytics/DiscountImpact.tsx @@ -0,0 +1,146 @@ +import { useQuery } from '@tanstack/react-query'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { + ResponsiveContainer, + BarChart, + Bar, + XAxis, + YAxis, + Tooltip, + CartesianGrid, + Legend, +} from 'recharts'; +import config from '../../config'; +import { METRIC_COLORS } from '@/lib/dashboard/designTokens'; +import { formatCurrency } from '@/utils/formatCurrency'; + +interface DiscountRow { + abcClass: string; + discountBucket: string; + productCount: number; + avgSellThrough: number; + revenue: number; + discountAmount: number; + profit: number; +} + +const CLASS_COLORS: Record = { + A: METRIC_COLORS.revenue, + B: METRIC_COLORS.orders, + C: METRIC_COLORS.comparison, +}; + +export function DiscountImpact() { + const { data, isLoading } = useQuery({ + queryKey: ['discount-impact'], + queryFn: async () => { + const response = await fetch(`${config.apiUrl}/analytics/discounts`); + if (!response.ok) throw new Error('Failed to fetch discount data'); + return response.json(); + }, + }); + + if (isLoading || !data) { + return ( + + Discount Impact + +
+
Loading discount data...
+
+
+
+ ); + } + + // Pivot: for each discount bucket, show avg sell-through by ABC class + const buckets = ['No Discount', '1-10%', '11-20%', '21-30%', '30%+']; + const chartData = buckets.map(bucket => { + const row: Record = { bucket }; + ['A', 'B', 'C'].forEach(cls => { + const match = data.find(d => d.discountBucket === bucket && d.abcClass === cls); + row[`Class ${cls}`] = match?.avgSellThrough || 0; + }); + return row; + }); + + // Summary by ABC class + const classSummary = ['A', 'B', 'C'].map(cls => { + const rows = data.filter(d => d.abcClass === cls); + return { + abcClass: cls, + totalProducts: rows.reduce((s, r) => s + r.productCount, 0), + totalDiscounts: rows.reduce((s, r) => s + r.discountAmount, 0), + totalRevenue: rows.reduce((s, r) => s + r.revenue, 0), + totalProfit: rows.reduce((s, r) => s + r.profit, 0), + }; + }); + + return ( +
+ + + Sell-Through by Discount Level +

+ Avg 30-day sell-through % at each discount bracket, by ABC class +

+
+ + + + + + `${v}%`} tick={{ fontSize: 11 }} /> + [`${value}%`]} /> + + + + + + + +
+ + + + Discount Leakage by Class +

+ How much discount is given relative to revenue per ABC class +

+
+ +
+ + + + + + + + + + + + + {classSummary.map((row) => ( + + + + + + + + + ))} + +
ClassProductsRevenueDiscountsDisc %Profit
Class {row.abcClass}{row.totalProducts.toLocaleString()}{formatCurrency(row.totalRevenue)}{formatCurrency(row.totalDiscounts)} + {row.totalRevenue > 0 + ? ((row.totalDiscounts / (row.totalRevenue + row.totalDiscounts)) * 100).toFixed(1) + : '0'}% + {formatCurrency(row.totalProfit)}
+
+
+
+
+ ); +} diff --git a/inventory/src/components/analytics/GrowthMomentum.tsx b/inventory/src/components/analytics/GrowthMomentum.tsx new file mode 100644 index 0000000..0f59a79 --- /dev/null +++ b/inventory/src/components/analytics/GrowthMomentum.tsx @@ -0,0 +1,160 @@ +import { useQuery } from '@tanstack/react-query'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { + ResponsiveContainer, + BarChart, + Bar, + XAxis, + YAxis, + Tooltip, + CartesianGrid, + Legend, +} from 'recharts'; +import config from '../../config'; +import { METRIC_COLORS } from '@/lib/dashboard/designTokens'; +import { TrendingUp, TrendingDown } from 'lucide-react'; + +interface GrowthRow { + abcClass: string; + growthBucket: string; + productCount: number; + revenue: number; + stockCost: number; +} + +interface GrowthSummary { + totalWithYoy: number; + growingCount: number; + decliningCount: number; + avgGrowth: number; + medianGrowth: number; +} + +interface GrowthData { + byClass: GrowthRow[]; + summary: GrowthSummary; +} + +const GROWTH_COLORS: Record = { + 'Strong Growth (>50%)': METRIC_COLORS.revenue, + 'Growing (0-50%)': '#34d399', + 'Declining (0-50%)': METRIC_COLORS.comparison, + 'Sharp Decline (>50%)': '#ef4444', +}; + +export function GrowthMomentum() { + const { data, isLoading } = useQuery({ + queryKey: ['growth-momentum'], + queryFn: async () => { + const response = await fetch(`${config.apiUrl}/analytics/growth`); + if (!response.ok) throw new Error('Failed to fetch growth data'); + return response.json(); + }, + }); + + if (isLoading || !data) { + return ( + + YoY Growth Momentum + +
+
Loading growth data...
+
+
+
+ ); + } + + const { summary } = data; + const growthPct = summary.totalWithYoy > 0 + ? ((summary.growingCount / summary.totalWithYoy) * 100).toFixed(0) + : '0'; + + // Pivot: for each ABC class, show product counts by growth bucket + const classes = ['A', 'B', 'C']; + const buckets = ['Strong Growth (>50%)', 'Growing (0-50%)', 'Declining (0-50%)', 'Sharp Decline (>50%)']; + const chartData = classes.map(cls => { + const row: Record = { abcClass: `Class ${cls}` }; + buckets.forEach(bucket => { + const match = data.byClass.find(d => d.abcClass === cls && d.growthBucket === bucket); + row[bucket] = match?.productCount || 0; + }); + return row; + }); + + return ( +
+
+ + +
+ +
+
+

Growing

+

{growthPct}%

+

{summary.growingCount.toLocaleString()} products

+
+
+
+ + +
+ +
+
+

Declining

+

{summary.decliningCount.toLocaleString()}

+

products

+
+
+
+ + +

Avg YoY Growth

+

= 0 ? 'text-green-500' : 'text-red-500'}`}> + {summary.avgGrowth > 0 ? '+' : ''}{summary.avgGrowth}% +

+
+
+ + +

Median YoY Growth

+

= 0 ? 'text-green-500' : 'text-red-500'}`}> + {summary.medianGrowth > 0 ? '+' : ''}{summary.medianGrowth}% +

+

{summary.totalWithYoy.toLocaleString()} products tracked

+
+
+
+ + + + Growth Distribution by ABC Class +

+ Year-over-year sales growth segmented by product importance +

+
+ + + + + + + + + {buckets.map(bucket => ( + + ))} + + + +
+
+ ); +} diff --git a/inventory/src/components/analytics/InventoryTrends.tsx b/inventory/src/components/analytics/InventoryTrends.tsx new file mode 100644 index 0000000..aa978c8 --- /dev/null +++ b/inventory/src/components/analytics/InventoryTrends.tsx @@ -0,0 +1,127 @@ +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { + ResponsiveContainer, + Bar, + XAxis, + YAxis, + Tooltip, + CartesianGrid, + Line, + ComposedChart, +} from 'recharts'; +import config from '../../config'; +import { METRIC_COLORS } from '@/lib/dashboard/designTokens'; + +interface TrendPoint { + date: string; + stockoutCount: number; + unitsSold: number; +} + +type Period = 30 | 90 | 365; + +function formatDate(dateStr: string, period: Period): string { + const d = new Date(dateStr); + if (period === 365) return d.toLocaleDateString('en-US', { month: 'short', year: '2-digit' }); + if (period === 90) return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); +} + +export function InventoryTrends() { + const [period, setPeriod] = useState(90); + + const { data, isLoading } = useQuery({ + queryKey: ['inventory-trends', period], + queryFn: async () => { + const response = await fetch(`${config.apiUrl}/analytics/inventory-trends?period=${period}`); + if (!response.ok) throw new Error('Failed to fetch inventory trends'); + return response.json(); + }, + }); + + return ( + + +
+
+ Daily Sales Activity & Stockouts +

+ Units sold per day with stockout product count overlay +

+
+
+ {([30, 90, 365] as Period[]).map((p) => ( + + ))} +
+
+
+ + {isLoading || !data ? ( +
+
Loading trends...
+
+ ) : ( + + + + formatDate(v, period)} + tick={{ fontSize: 12 }} + interval={period === 365 ? 29 : period === 90 ? 6 : 2} + /> + + + new Date(v).toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' })} + formatter={(value: number, name: string) => [value.toLocaleString(), name]} + /> + + + + + )} +
+
+ ); +} diff --git a/inventory/src/components/analytics/PortfolioAnalysis.tsx b/inventory/src/components/analytics/PortfolioAnalysis.tsx new file mode 100644 index 0000000..3ce520b --- /dev/null +++ b/inventory/src/components/analytics/PortfolioAnalysis.tsx @@ -0,0 +1,213 @@ +import { useQuery } from '@tanstack/react-query'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { + ResponsiveContainer, + BarChart, + Bar, + XAxis, + YAxis, + Tooltip, + Legend, + CartesianGrid, +} from 'recharts'; +import config from '../../config'; +import { PackageX, Archive } from 'lucide-react'; +import { METRIC_COLORS } from '@/lib/dashboard/designTokens'; +import { formatCurrency } from '@/utils/formatCurrency'; + +interface AbcItem { + abcClass: string; + productCount: number; + revenue: number; + stockCost: number; + profit: number; + unitsSold: number; +} + +interface StockIssues { + deadStockCount: number; + deadStockCost: number; + deadStockRetail: number; + overstockCount: number; + overstockCost: number; + overstockRetail: number; +} + +interface PortfolioData { + abcBreakdown: AbcItem[]; + stockIssues: StockIssues; +} + +export function PortfolioAnalysis() { + const { data, isLoading } = useQuery({ + queryKey: ['portfolio-analysis'], + queryFn: async () => { + const response = await fetch(`${config.apiUrl}/analytics/portfolio`); + if (!response.ok) throw new Error('Failed to fetch portfolio analysis'); + return response.json(); + }, + }); + + if (isLoading || !data) { + return ( + + Portfolio & ABC Analysis + +
+
Loading portfolio...
+
+
+
+ ); + } + + // Include all classes — rename N/A to "Unclassified" + const allClasses = data.abcBreakdown.map(r => ({ + ...r, + abcClass: r.abcClass === 'N/A' ? 'Unclassified' : r.abcClass, + })); + const totalRevenue = allClasses.reduce((s, r) => s + r.revenue, 0); + const totalStockCost = allClasses.reduce((s, r) => s + r.stockCost, 0); + const totalProducts = allClasses.reduce((s, r) => s + r.productCount, 0); + + // Compute percentage data for the grouped bar chart + const chartData = allClasses.map(r => ({ + abcClass: r.abcClass === 'Unclassified' ? 'Unclassified' : `Class ${r.abcClass}`, + '% of Products': totalProducts > 0 ? Number(((r.productCount / totalProducts) * 100).toFixed(1)) : 0, + '% of Revenue': totalRevenue > 0 ? Number(((r.revenue / totalRevenue) * 100).toFixed(1)) : 0, + '% of Stock Investment': totalStockCost > 0 ? Number(((r.stockCost / totalStockCost) * 100).toFixed(1)) : 0, + })); + + const abcOnly = allClasses.filter(r => ['A', 'B', 'C'].includes(r.abcClass)); + const abcRevenue = abcOnly.reduce((s, r) => s + r.revenue, 0); + const aClass = allClasses.find(r => r.abcClass === 'A'); + + return ( +
+
+ + + ABC Class Distribution + + + + + + + `${v}%`} tick={{ fontSize: 12 }} /> + [`${value}%`]} /> + + + + + + + + + +
+ + +
+ +
+
+

A-Class Products

+

+ {aClass ? aClass.productCount.toLocaleString() : 0} products +

+
+
+

+ {abcRevenue > 0 && aClass ? ((aClass.revenue / abcRevenue) * 100).toFixed(0) : 0}% of classified revenue +

+

+ {formatCurrency(aClass?.revenue || 0)} (30d) +

+
+
+
+ + +
+ +
+
+

Dead Stock

+

+ {data.stockIssues.deadStockCount.toLocaleString()} products +

+
+
+

+ {formatCurrency(data.stockIssues.deadStockCost)} +

+

capital tied up

+
+
+
+ + +
+ +
+
+

Overstock

+

+ {data.stockIssues.overstockCount.toLocaleString()} products +

+
+
+

+ {formatCurrency(data.stockIssues.overstockCost)} +

+

excess investment

+
+
+
+
+
+ + {/* ABC breakdown table */} + + +
+ + + + + + + + + + + + + {allClasses.map((row) => ( + + + + + + + + + ))} + +
ClassProductsRevenue (30d)Profit (30d)Stock CostUnits Sold (30d)
{row.abcClass === 'Unclassified' ? 'Unclassified' : `Class ${row.abcClass}`}{row.productCount.toLocaleString()}{formatCurrency(row.revenue)}{formatCurrency(row.profit)}{formatCurrency(row.stockCost)}{row.unitsSold.toLocaleString()}
+
+
+
+
+ ); +} + +function TrendingUpIcon({ className }: { className?: string }) { + return ( + + + + + ); +} diff --git a/inventory/src/components/analytics/PriceAnalysis.tsx b/inventory/src/components/analytics/PriceAnalysis.tsx deleted file mode 100644 index aa646b5..0000000 --- a/inventory/src/components/analytics/PriceAnalysis.tsx +++ /dev/null @@ -1,232 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { ResponsiveContainer, ScatterChart, Scatter, XAxis, YAxis, Tooltip, ZAxis, LineChart, Line } from 'recharts'; -import config from '../../config'; - -interface PriceData { - pricePoints: { - price: number; - salesVolume: number; - revenue: number; - category: string; - }[]; - elasticity: { - date: string; - price: number; - demand: number; - }[]; - recommendations: { - product: string; - currentPrice: number; - recommendedPrice: number; - potentialRevenue: number; - confidence: number; - }[]; -} - -export function PriceAnalysis() { - const { data, isLoading, error } = useQuery({ - queryKey: ['price-analysis'], - queryFn: async () => { - 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; - } - }, - retry: 1 - }); - - 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 ( -
-
- - - Price Point Analysis - - - - - `$${value}`} - /> - - - { - if (name === 'Price') return [`$${value}`, name]; - if (name === 'Sales Volume') return [value.toLocaleString(), name]; - if (name === 'Revenue') return [`$${value.toLocaleString()}`, name]; - return [value, name]; - }} - /> - - - - - - - - - Price Elasticity - - - - - new Date(value).toLocaleDateString()} - /> - - `$${value}`} - /> - new Date(label).toLocaleDateString()} - formatter={(value: number, name: string) => { - if (name === 'Price') return [`$${value}`, name]; - return [value.toLocaleString(), name]; - }} - /> - - - - - - -
- - - - Price Optimization Recommendations - - -
- {data.recommendations.map((item) => ( -
-
-

{item.product}

-

- Current Price: ${item.currentPrice.toFixed(2)} -

-
-
-

- Recommended: ${item.recommendedPrice.toFixed(2)} -

-

- Potential Revenue: ${item.potentialRevenue.toLocaleString()} -

-

- Confidence: {item.confidence}% -

-
-
- ))} -
-
-
-
- ); -} \ No newline at end of file diff --git a/inventory/src/components/analytics/ProfitAnalysis.tsx b/inventory/src/components/analytics/ProfitAnalysis.tsx deleted file mode 100644 index ae54da1..0000000 --- a/inventory/src/components/analytics/ProfitAnalysis.tsx +++ /dev/null @@ -1,180 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip, LineChart, Line } from 'recharts'; -import config from '../../config'; - -interface ProfitData { - byCategory: { - category: string; - categoryPath: string; // Full hierarchy path - profitMargin: number; - revenue: number; - cost: number; - }[]; - overTime: { - date: string; - profitMargin: number; - revenue: number; - cost: number; - }[]; - topProducts: { - product: string; - category: string; - categoryPath: string; // Full hierarchy path - profitMargin: number; - revenue: number; - cost: number; - }[]; -} - -export function ProfitAnalysis() { - const { data, isLoading } = useQuery({ - queryKey: ['profit-analysis'], - queryFn: async () => { - const response = await fetch(`${config.apiUrl}/analytics/profit`); - if (!response.ok) { - throw new Error('Failed to fetch profit analysis'); - } - const rawData = await response.json(); - return { - byCategory: rawData.byCategory.map((item: any) => ({ - 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) => ({ - 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) => ({ - 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 - })) - }; - }, - }); - - if (isLoading || !data) { - return
Loading profit analysis...
; - } - - const getShortCategoryName = (path: string) => path.split(' > ').pop() || path; - - return ( -
-
- - - Profit Margins by Category - - - - - ( - - - {getShortCategoryName(payload.value)} - - - )} - /> - `${value}%`} /> - [ - `${value.toFixed(1)}%`, -
-
Category Path:
-
{props.payload.categoryPath}
-
Profit Margin
-
- ]} - /> - -
-
-
-
- - - - Profit Margin Trend - - - - - new Date(value).toLocaleDateString()} - /> - `${value}%`} /> - new Date(label).toLocaleDateString()} - formatter={(value: number) => [`${value.toFixed(1)}%`, 'Profit Margin']} - /> - - - - - -
- - - - Top Performing Products by Profit Margin - - -
- {data.topProducts.map((product) => ( -
-
-

{product.product}

-
-

Category:

-

{product.categoryPath}

-
-

- Revenue: ${product.revenue.toLocaleString()} -

-
-
-

- {product.profitMargin.toFixed(1)}% margin -

-

- Cost: ${product.cost.toLocaleString()} -

-
-
- ))} -
-
-
-
- ); -} \ No newline at end of file diff --git a/inventory/src/components/analytics/StockAnalysis.tsx b/inventory/src/components/analytics/StockAnalysis.tsx deleted file mode 100644 index 74c2756..0000000 --- a/inventory/src/components/analytics/StockAnalysis.tsx +++ /dev/null @@ -1,227 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip, LineChart, Line } from 'recharts'; -import { Badge } from '@/components/ui/badge'; -import config from '../../config'; - -interface StockData { - turnoverByCategory: { - category: string; - turnoverRate: number; - averageStock: number; - totalSales: number; - }[]; - stockLevels: { - date: string; - inStock: number; - lowStock: number; - outOfStock: number; - }[]; - criticalItems: { - product: string; - sku: string; - stockQuantity: number; - reorderPoint: number; - turnoverRate: number; - daysUntilStockout: number; - }[]; -} - -export function StockAnalysis() { - const { data, isLoading, error } = useQuery({ - queryKey: ['stock-analysis'], - queryFn: async () => { - 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; - } - }, - retry: 1 - }); - - 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; - } - if (daysUntilStockout <= 14) { - return Warning; - } - return OK; - }; - - return ( -
-
- - - Stock Turnover by Category - - - - - - `${value.toFixed(1)}x`} /> - [`${value.toFixed(1)}x`, 'Turnover Rate']} - /> - - - - - - - - - Stock Level Trends - - - - - new Date(value).toLocaleDateString()} - /> - - new Date(label).toLocaleDateString()} - /> - - - - - - - -
- - - - Critical Stock Items - - -
- {data.criticalItems.map((item) => ( -
-
-
-

{item.product}

- {getStockStatus(item.daysUntilStockout)} -
-

- SKU: {item.sku} -

-
-
-

- {item.stockQuantity} in stock -

-

- Reorder at: {item.reorderPoint} -

-

- {item.daysUntilStockout} days until stockout -

-
-
- ))} -
-
-
-
- ); -} \ No newline at end of file diff --git a/inventory/src/components/analytics/StockHealth.tsx b/inventory/src/components/analytics/StockHealth.tsx new file mode 100644 index 0000000..fc9694e --- /dev/null +++ b/inventory/src/components/analytics/StockHealth.tsx @@ -0,0 +1,244 @@ +import { useQuery } from '@tanstack/react-query'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { + ResponsiveContainer, + BarChart, + Bar, + XAxis, + YAxis, + Tooltip, + CartesianGrid, + PieChart, + Pie, + Cell, + Legend, +} from 'recharts'; +import config from '../../config'; +import { AlertTriangle, ShieldCheck, DollarSign } from 'lucide-react'; +import { METRIC_COLORS } from '@/lib/dashboard/designTokens'; +import { formatCurrency } from '@/utils/formatCurrency'; + +interface CoverBucket { + bucket: string; + productCount: number; + stockCost: number; +} + +interface DemandPattern { + pattern: string; + productCount: number; + revenue: number; + stockCost: number; +} + +interface ServiceStats { + avgFillRate: number; + avgServiceLevel: number; + totalStockoutIncidents: number; + totalLostSalesIncidents: number; + totalLostUnits: number; + totalLostRevenue: number; + productsWithStockouts: number; + avgStockoutRate: number; +} + +interface StockHealthData { + coverDistribution: CoverBucket[]; + demandPatterns: DemandPattern[]; + serviceStats: ServiceStats; +} + +// Color palette for demand pattern donut chart +const DEMAND_COLORS = [ + METRIC_COLORS.revenue, // emerald + METRIC_COLORS.orders, // blue + METRIC_COLORS.comparison, // amber + METRIC_COLORS.aov, // violet + METRIC_COLORS.secondary, // cyan +]; + +function getCoverColor(bucket: string): string { + if (bucket.includes('Stockout')) return '#ef4444'; // red + if (bucket.includes('1-7')) return METRIC_COLORS.expense; // orange — critical low + if (bucket.includes('8-14')) return METRIC_COLORS.comparison; // amber — low + if (bucket.includes('15-30')) return '#eab308'; // yellow — watch + if (bucket.includes('31-60')) return METRIC_COLORS.revenue; // emerald — healthy + if (bucket.includes('61-90')) return METRIC_COLORS.orders; // blue — comfortable + if (bucket.includes('91-180')) return METRIC_COLORS.aov; // violet — high + return METRIC_COLORS.secondary; // cyan — excess +} + +export function StockHealth() { + const { data, isLoading } = useQuery({ + queryKey: ['stock-health'], + queryFn: async () => { + const response = await fetch(`${config.apiUrl}/analytics/stock-health`); + if (!response.ok) throw new Error('Failed to fetch stock health'); + return response.json(); + }, + }); + + if (isLoading || !data) { + return ( + + Demand & Stock Health + +
+
Loading stock health...
+
+
+
+ ); + } + + const { serviceStats } = data; + + return ( +
+ {/* Service Level Stats */} +
+ + +
+ +
+
+

Fill Rate

+

{serviceStats.avgFillRate}%

+
+
+
+ + +
+ +
+
+

Service Level

+

{serviceStats.avgServiceLevel}%

+
+
+
+ + +
+ +
+
+

Stockout Incidents

+

{serviceStats.totalStockoutIncidents.toLocaleString()}

+

{serviceStats.productsWithStockouts} products affected

+
+
+
+ + +
+ +
+
+

Est. Lost Revenue

+

{formatCurrency(serviceStats.totalLostRevenue)}

+

{Math.round(serviceStats.totalLostUnits).toLocaleString()} units

+
+
+
+
+ +
+ {/* Stock Cover Distribution */} + + + Stock Cover Distribution +

+ Days of stock cover across active replenishable products +

+
+ + + + + + + { + if (!active || !payload?.length) return null; + const d = payload[0].payload as CoverBucket; + return ( +
+

{d.bucket}

+

{d.productCount.toLocaleString()} products

+

Stock value: {formatCurrency(d.stockCost)}

+
+ ); + }} + /> + + {data.coverDistribution.map((entry, i) => ( + + ))} + +
+
+
+
+ + {/* Demand Pattern Distribution */} + + + Demand Patterns +

+ Distribution of demand variability across selling products +

+
+ + + + + `${pattern} (${productCount.toLocaleString()})` + } + > + {data.demandPatterns.map((_, i) => ( + + ))} + + { + if (!active || !payload?.length) return null; + const d = payload[0].payload as DemandPattern; + return ( +
+

{d.pattern}

+

{d.productCount.toLocaleString()} products

+

Revenue (30d): {formatCurrency(d.revenue)}

+

Stock value: {formatCurrency(d.stockCost)}

+
+ ); + }} + /> + {value}} + /> +
+
+
+
+
+
+ ); +} diff --git a/inventory/src/components/analytics/StockoutRisk.tsx b/inventory/src/components/analytics/StockoutRisk.tsx new file mode 100644 index 0000000..c2b6f23 --- /dev/null +++ b/inventory/src/components/analytics/StockoutRisk.tsx @@ -0,0 +1,174 @@ +import { useQuery } from '@tanstack/react-query'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { + ResponsiveContainer, + ScatterChart, + Scatter, + XAxis, + YAxis, + Tooltip, + CartesianGrid, + ZAxis, + Cell, +} from 'recharts'; +import config from '../../config'; +import { METRIC_COLORS } from '@/lib/dashboard/designTokens'; +import { formatCurrency } from '@/utils/formatCurrency'; + +interface RiskProduct { + title: string; + sku: string; + vendor: string; + leadTimeDays: number; + sellsOutInDays: number; + currentStock: number; + velocityDaily: number; + revenue30d: number; + abcClass: string; +} + +interface RiskSummary { + atRiskCount: number; + criticalACount: number; + atRiskRevenue: number; +} + +interface StockoutRiskData { + summary: RiskSummary; + products: RiskProduct[]; +} + +function getRiskColor(product: RiskProduct): string { + const buffer = product.sellsOutInDays - product.leadTimeDays; + if (buffer <= 0) return '#ef4444'; // Already past lead time — critical + if (buffer <= 7) return METRIC_COLORS.comparison; // Within a week — warning + return METRIC_COLORS.revenue; // Healthy buffer +} + +export function StockoutRisk() { + const { data, isLoading } = useQuery({ + queryKey: ['stockout-risk'], + queryFn: async () => { + const response = await fetch(`${config.apiUrl}/analytics/stockout-risk`); + if (!response.ok) throw new Error('Failed to fetch stockout risk'); + return response.json(); + }, + }); + + if (isLoading || !data) { + return ( + + Reorder Risk + +
+
Loading risk data...
+
+
+
+ ); + } + + const { summary, products } = data; + + return ( +
+
+ + +

At Risk Products

+

{summary.atRiskCount}

+

sells out before lead time

+
+
+ + +

Critical A-Class

+

{summary.criticalACount}

+

top sellers at risk

+
+
+ + +

At-Risk Revenue

+

+ {formatCurrency(summary.atRiskRevenue)} +

+

monthly revenue exposed

+
+
+
+ + + + Lead Time vs Sell-Out Timeline +

+ Products below the diagonal line will stock out before replenishment arrives (incl. on-order stock) +

+
+ + + + + + + + {/* Diagonal risk line (y = x): products below this stock out before replenishment */} + { + const max = Math.max(...products.map(d => Math.max(d.leadTimeDays, d.sellsOutInDays))); + return [ + { leadTimeDays: 0, sellsOutInDays: 0, revenue30d: 0 }, + { leadTimeDays: max, sellsOutInDays: max, revenue30d: 0 }, + ]; + })()} + line={{ stroke: '#9ca3af', strokeDasharray: '6 3', strokeWidth: 1.5 }} + shape={() => } + legendType="none" + isAnimationActive={false} + /> + { + if (!active || !payload?.length) return null; + const d = payload[0].payload as RiskProduct; + if (!d.title) return null; // skip diagonal line points + const buffer = d.sellsOutInDays - d.leadTimeDays; + return ( +
+

{d.title}

+

{d.sku} ({d.abcClass})

+

Lead time: {d.leadTimeDays}d

+

Sells out in: {d.sellsOutInDays}d

+

+ Buffer: {buffer}d {buffer <= 0 ? '(AT RISK)' : ''} +

+

Stock: {d.currentStock} units

+

Velocity: {d.velocityDaily.toFixed(1)}/day

+

Revenue (30d): {formatCurrency(d.revenue30d)}

+
+ ); + }} + /> + + {products.map((entry, i) => ( + + ))} + +
+
+
+
+
+ ); +} diff --git a/inventory/src/components/analytics/VendorPerformance.tsx b/inventory/src/components/analytics/VendorPerformance.tsx deleted file mode 100644 index e4ce60f..0000000 --- a/inventory/src/components/analytics/VendorPerformance.tsx +++ /dev/null @@ -1,230 +0,0 @@ -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip, ScatterChart, Scatter, ZAxis } from 'recharts'; -import config from '../../config'; -import { useState, useEffect } from 'react'; - -interface VendorData { - performance: { - vendor: string; - salesVolume: number; - profitMargin: number; - stockTurnover: number; - productCount: number; - growth: number; - }[]; - comparison?: { - vendor: string; - salesPerProduct: number; - averageMargin: number; - size: number; - }[]; - trends?: { - vendor: string; - month: string; - sales: number; - }[]; -} - -export function VendorPerformance() { - const [vendorData, setVendorData] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - // Use plain fetch to bypass cache issues with React Query - const fetchData = async () => { - try { - setIsLoading(true); - - // Add cache-busting parameter - const response = await fetch(`${config.apiUrl}/analytics/vendors?nocache=${Date.now()}`, { - headers: { - "Cache-Control": "no-cache, no-store, must-revalidate", - "Pragma": "no-cache", - "Expires": "0" - } - }); - - if (!response.ok) { - throw new Error(`Failed to fetch: ${response.status}`); - } - - const rawData = await response.json(); - - if (!rawData || !rawData.performance) { - throw new Error('Invalid response format'); - } - - // Create a complete structure even if some parts are missing - const data: VendorData = { - performance: rawData.performance.map((vendor: any) => ({ - 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: vendor.growth !== null ? Number(vendor.growth) : 0 - })), - comparison: rawData.comparison?.map((vendor: any) => ({ - 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 || '', - sales: Number(vendor.sales) || 0 - })) || [] - }; - - setVendorData(data); - } catch (err) { - console.error('Error fetching vendor data:', err); - setError(err instanceof Error ? err.message : 'Unknown error'); - } finally { - setIsLoading(false); - } - }; - - fetchData(); - }, []); - - if (isLoading) { - return
Loading vendor performance...
; - } - - if (error || !vendorData) { - return
Error loading vendor data: {error}
; - } - - // Ensure we have at least the performance data - const sortedPerformance = vendorData.performance - .sort((a, b) => b.salesVolume - a.salesVolume) - .slice(0, 10); - - // Use simplified version if comparison data is missing - const hasComparisonData = vendorData.comparison && vendorData.comparison.length > 0; - - return ( -
-
- - - Top Vendors by Sales Volume - - - - - - `$${(value / 1000).toFixed(0)}k`} /> - [`$${value.toLocaleString()}`, 'Sales Volume']} - /> - - - - - - - {hasComparisonData ? ( - - - Vendor Performance Matrix - - - - - `$${(value / 1000).toFixed(0)}k`} - /> - `${value.toFixed(0)}%`} - /> - - { - if (name === 'Sales per Product') return [`$${value.toLocaleString()}`, name]; - if (name === 'Average Margin') return [`${value.toFixed(1)}%`, name]; - return [value, name]; - }} - /> - - - - - - ) : ( - - - Vendor Profit Margins - - - - - - `${value}%`} /> - [`${value.toFixed(1)}%`, 'Profit Margin']} - /> - - - - - - )} -
- - - - Vendor Performance Details - - -
- {sortedPerformance.map((vendor) => ( -
-
-

{vendor.vendor}

-

- {vendor.productCount} products -

-
-
-

- ${vendor.salesVolume.toLocaleString()} sales -

-

- {vendor.profitMargin.toFixed(1)}% margin -

-

- {vendor.stockTurnover.toFixed(1)}x turnover -

-
-
- ))} -
-
-
-
- ); -} \ No newline at end of file diff --git a/inventory/src/pages/Analytics.tsx b/inventory/src/pages/Analytics.tsx index bb21e04..0f5493f 100644 --- a/inventory/src/pages/Analytics.tsx +++ b/inventory/src/pages/Analytics.tsx @@ -1,113 +1,142 @@ import { useQuery } from '@tanstack/react-query'; import { Card, CardContent, CardHeader, CardTitle } from '../components/ui/card'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '../components/ui/tabs'; -import { ProfitAnalysis } from '../components/analytics/ProfitAnalysis'; -import { VendorPerformance } from '../components/analytics/VendorPerformance'; -import { StockAnalysis } from '../components/analytics/StockAnalysis'; -import { PriceAnalysis } from '../components/analytics/PriceAnalysis'; -import { CategoryPerformance } from '../components/analytics/CategoryPerformance'; +import { InventoryTrends } from '../components/analytics/InventoryTrends'; +import { PortfolioAnalysis } from '../components/analytics/PortfolioAnalysis'; +import { CapitalEfficiency } from '../components/analytics/CapitalEfficiency'; +import { StockHealth } from '../components/analytics/StockHealth'; +import { AgingSellThrough } from '../components/analytics/AgingSellThrough'; +import { StockoutRisk } from '../components/analytics/StockoutRisk'; +import { DiscountImpact } from '../components/analytics/DiscountImpact'; +import { GrowthMomentum } from '../components/analytics/GrowthMomentum'; import config from '../config'; import { motion } from 'motion/react'; +import { DollarSign, RefreshCw, TrendingUp, Calendar } from 'lucide-react'; +import { formatCurrency } from '../utils/formatCurrency'; -interface AnalyticsStats { - profitMargin: number; - averageMarkup: number; - stockTurnoverRate: number; - vendorCount: number; - categoryCount: number; - averageOrderValue: number; +interface InventorySummary { + stockInvestment: number; + onOrderValue: number; + inventoryTurns: number; + gmroi: number; + avgStockCoverDays: number; + productsInStock: number; + deadStockProducts: number; + deadStockValue: number; } export function Analytics() { - const { data: stats, isLoading: statsLoading } = useQuery({ - queryKey: ['analytics-stats'], + const { data: summary, isLoading } = useQuery({ + queryKey: ['inventory-summary'], queryFn: async () => { - const response = await fetch(`${config.apiUrl}/analytics/stats`); - if (!response.ok) { - throw new Error('Failed to fetch analytics stats'); - } + const response = await fetch(`${config.apiUrl}/analytics/inventory-summary`); + if (!response.ok) throw new Error('Failed to fetch inventory summary'); return response.json(); }, }); - if (statsLoading || !stats) { - return
Loading analytics...
; - } - return ( - -
+ +

Analytics

- -
+ + {/* KPI Summary Cards */} +
- - Overall Profit Margin - + Stock Investment + -
- {stats.profitMargin.toFixed(1)}% -
+ {isLoading || !summary ? ( +
+ ) : ( + <> +
{formatCurrency(summary.stockInvestment)}
+

+ {formatCurrency(summary.onOrderValue)} on order +

+ + )} - - Average Markup - + Inventory Turns + -
- {stats.averageMarkup.toFixed(1)}% -
+ {isLoading || !summary ? ( +
+ ) : ( + <> +
{summary.inventoryTurns.toFixed(1)}x
+

annualized (30d basis)

+ + )} - - Stock Turnover Rate - + GMROI + -
- {stats.stockTurnoverRate.toFixed(2)}x -
+ {isLoading || !summary ? ( +
+ ) : ( + <> +
{summary.gmroi.toFixed(2)}
+

+ profit per $ invested (30d) +

+ + )} + + + + + Avg Stock Cover + + + + {isLoading || !summary ? ( +
+ ) : ( + <> +
{Math.round(summary.avgStockCoverDays)} days
+

+ {summary.productsInStock.toLocaleString()} products in stock +

+ + )}
- - - Profit - Vendors - Stock - Pricing - Categories - - - - - - - - - - - - - - - - - - - - - - + {/* Section 2: Inventory Value Trends */} + + + {/* Section 3: ABC Portfolio Analysis */} + + + {/* Section 4: Capital Efficiency */} + + + {/* Section 5: Demand & Stock Health */} + + + {/* Section 6: Aging & Sell-Through */} + + + {/* Section 7: Reorder Risk */} + + + {/* Section 8: Discount Impact */} + + + {/* Section 9: YoY Growth Momentum */} + ); -} \ No newline at end of file +} diff --git a/inventory/src/utils/formatCurrency.ts b/inventory/src/utils/formatCurrency.ts new file mode 100644 index 0000000..9eb2b58 --- /dev/null +++ b/inventory/src/utils/formatCurrency.ts @@ -0,0 +1,5 @@ +export function formatCurrency(value: number): string { + if (value >= 1_000_000) return `$${(value / 1_000_000).toFixed(1)}M`; + if (value >= 1_000) return `$${(value / 1_000).toFixed(1)}k`; + return `$${value.toFixed(0)}`; +} diff --git a/inventory/tsconfig.tsbuildinfo b/inventory/tsconfig.tsbuildinfo index 607578c..8df6c2d 100644 --- a/inventory/tsconfig.tsbuildinfo +++ b/inventory/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/config.ts","./src/main.tsx","./src/vite-env.d.ts","./src/components/config.ts","./src/components/analytics/categoryperformance.tsx","./src/components/analytics/priceanalysis.tsx","./src/components/analytics/profitanalysis.tsx","./src/components/analytics/stockanalysis.tsx","./src/components/analytics/vendorperformance.tsx","./src/components/auth/firstaccessiblepage.tsx","./src/components/auth/protected.tsx","./src/components/auth/requireauth.tsx","./src/components/chat/chatroom.tsx","./src/components/chat/chattest.tsx","./src/components/chat/roomlist.tsx","./src/components/chat/searchresults.tsx","./src/components/dashboard/financialoverview.tsx","./src/components/dashboard/operationsmetrics.tsx","./src/components/dashboard/payrollmetrics.tsx","./src/components/dashboard/periodselectionpopover.tsx","./src/components/dashboard/shared/dashboardbadge.tsx","./src/components/dashboard/shared/dashboardcharttooltip.tsx","./src/components/dashboard/shared/dashboardsectionheader.tsx","./src/components/dashboard/shared/dashboardskeleton.tsx","./src/components/dashboard/shared/dashboardstatcard.tsx","./src/components/dashboard/shared/dashboardstatcardmini.tsx","./src/components/dashboard/shared/dashboardstates.tsx","./src/components/dashboard/shared/dashboardtable.tsx","./src/components/dashboard/shared/index.ts","./src/components/discount-simulator/configpanel.tsx","./src/components/discount-simulator/resultschart.tsx","./src/components/discount-simulator/resultstable.tsx","./src/components/discount-simulator/summarycard.tsx","./src/components/forecasting/daterangepickerquick.tsx","./src/components/forecasting/quickorderbuilder.tsx","./src/components/forecasting/columns.tsx","./src/components/layout/appsidebar.tsx","./src/components/layout/mainlayout.tsx","./src/components/layout/navuser.tsx","./src/components/newsletter/campaignhistorydialog.tsx","./src/components/newsletter/newsletterstats.tsx","./src/components/newsletter/recommendationtable.tsx","./src/components/overview/bestsellers.tsx","./src/components/overview/forecastmetrics.tsx","./src/components/overview/overstockmetrics.tsx","./src/components/overview/overview.tsx","./src/components/overview/purchasemetrics.tsx","./src/components/overview/replenishmentmetrics.tsx","./src/components/overview/salesmetrics.tsx","./src/components/overview/stockmetrics.tsx","./src/components/overview/topoverstockedproducts.tsx","./src/components/overview/topreplenishproducts.tsx","./src/components/overview/vendorperformance.tsx","./src/components/product-editor/comboboxfield.tsx","./src/components/product-editor/editablecomboboxfield.tsx","./src/components/product-editor/editableinput.tsx","./src/components/product-editor/editablemultiselect.tsx","./src/components/product-editor/imagemanager.tsx","./src/components/product-editor/producteditform.tsx","./src/components/product-editor/productsearch.tsx","./src/components/product-editor/types.ts","./src/components/product-import/createproductcategorydialog.tsx","./src/components/product-import/reactspreadsheetimport.tsx","./src/components/product-import/config.ts","./src/components/product-import/index.ts","./src/components/product-import/translationsrsiprops.ts","./src/components/product-import/types.ts","./src/components/product-import/components/closeconfirmationdialog.tsx","./src/components/product-import/components/modalwrapper.tsx","./src/components/product-import/components/providers.tsx","./src/components/product-import/components/savesessiondialog.tsx","./src/components/product-import/components/savedsessionslist.tsx","./src/components/product-import/components/table.tsx","./src/components/product-import/hooks/usersi.ts","./src/components/product-import/steps/steps.tsx","./src/components/product-import/steps/uploadflow.tsx","./src/components/product-import/steps/imageuploadstep/imageuploadstep.tsx","./src/components/product-import/steps/imageuploadstep/types.ts","./src/components/product-import/steps/imageuploadstep/components/droppablecontainer.tsx","./src/components/product-import/steps/imageuploadstep/components/genericdropzone.tsx","./src/components/product-import/steps/imageuploadstep/components/unassignedimagessection.tsx","./src/components/product-import/steps/imageuploadstep/components/productcard/copybutton.tsx","./src/components/product-import/steps/imageuploadstep/components/productcard/imagedropzone.tsx","./src/components/product-import/steps/imageuploadstep/components/productcard/productcard.tsx","./src/components/product-import/steps/imageuploadstep/components/productcard/sortableimage.tsx","./src/components/product-import/steps/imageuploadstep/components/unassignedimagessection/unassignedimageitem.tsx","./src/components/product-import/steps/imageuploadstep/hooks/usebulkimageupload.ts","./src/components/product-import/steps/imageuploadstep/hooks/usedraganddrop.ts","./src/components/product-import/steps/imageuploadstep/hooks/useproductimageoperations.ts","./src/components/product-import/steps/imageuploadstep/hooks/useproductimagesinit.ts","./src/components/product-import/steps/imageuploadstep/hooks/useurlimageupload.ts","./src/components/product-import/steps/matchcolumnsstep/matchcolumnsstep.tsx","./src/components/product-import/steps/matchcolumnsstep/types.ts","./src/components/product-import/steps/matchcolumnsstep/components/matchicon.tsx","./src/components/product-import/steps/matchcolumnsstep/components/templatecolumn.tsx","./src/components/product-import/steps/matchcolumnsstep/utils/findmatch.ts","./src/components/product-import/steps/matchcolumnsstep/utils/findunmatchedrequiredfields.ts","./src/components/product-import/steps/matchcolumnsstep/utils/getfieldoptions.ts","./src/components/product-import/steps/matchcolumnsstep/utils/getmatchedcolumns.ts","./src/components/product-import/steps/matchcolumnsstep/utils/normalizecheckboxvalue.ts","./src/components/product-import/steps/matchcolumnsstep/utils/normalizetabledata.ts","./src/components/product-import/steps/matchcolumnsstep/utils/setcolumn.ts","./src/components/product-import/steps/matchcolumnsstep/utils/setignorecolumn.ts","./src/components/product-import/steps/matchcolumnsstep/utils/setsubcolumn.ts","./src/components/product-import/steps/matchcolumnsstep/utils/uniqueentries.ts","./src/components/product-import/steps/selectheaderstep/selectheaderstep.tsx","./src/components/product-import/steps/selectheaderstep/components/selectheadertable.tsx","./src/components/product-import/steps/selectheaderstep/components/columns.tsx","./src/components/product-import/steps/selectsheetstep/selectsheetstep.tsx","./src/components/product-import/steps/uploadstep/uploadstep.tsx","./src/components/product-import/steps/uploadstep/components/dropzone.tsx","./src/components/product-import/steps/uploadstep/components/columns.tsx","./src/components/product-import/steps/uploadstep/utils/readfilesasync.ts","./src/components/product-import/steps/validationstep/index.tsx","./src/components/product-import/steps/validationstep/components/aisuggestionbadge.tsx","./src/components/product-import/steps/validationstep/components/copydownbanner.tsx","./src/components/product-import/steps/validationstep/components/floatingselectionbar.tsx","./src/components/product-import/steps/validationstep/components/initializingoverlay.tsx","./src/components/product-import/steps/validationstep/components/searchabletemplateselect.tsx","./src/components/product-import/steps/validationstep/components/suggestionbadges.tsx","./src/components/product-import/steps/validationstep/components/validationcontainer.tsx","./src/components/product-import/steps/validationstep/components/validationfooter.tsx","./src/components/product-import/steps/validationstep/components/validationtable.tsx","./src/components/product-import/steps/validationstep/components/validationtoolbar.tsx","./src/components/product-import/steps/validationstep/components/cells/checkboxcell.tsx","./src/components/product-import/steps/validationstep/components/cells/comboboxcell.tsx","./src/components/product-import/steps/validationstep/components/cells/inputcell.tsx","./src/components/product-import/steps/validationstep/components/cells/multiselectcell.tsx","./src/components/product-import/steps/validationstep/components/cells/multilineinput.tsx","./src/components/product-import/steps/validationstep/components/cells/selectcell.tsx","./src/components/product-import/steps/validationstep/contexts/aisuggestionscontext.tsx","./src/components/product-import/steps/validationstep/dialogs/aidebugdialog.tsx","./src/components/product-import/steps/validationstep/dialogs/aivalidationprogress.tsx","./src/components/product-import/steps/validationstep/dialogs/aivalidationresults.tsx","./src/components/product-import/steps/validationstep/dialogs/sanitycheckdialog.tsx","./src/components/product-import/steps/validationstep/hooks/useautoinlineaivalidation.ts","./src/components/product-import/steps/validationstep/hooks/usecopydownvalidation.ts","./src/components/product-import/steps/validationstep/hooks/usefieldoptions.ts","./src/components/product-import/steps/validationstep/hooks/useinlineaivalidation.ts","./src/components/product-import/steps/validationstep/hooks/useproductlines.ts","./src/components/product-import/steps/validationstep/hooks/usesanitycheck.ts","./src/components/product-import/steps/validationstep/hooks/usetemplatemanagement.ts","./src/components/product-import/steps/validationstep/hooks/useupcvalidation.ts","./src/components/product-import/steps/validationstep/hooks/usevalidationactions.ts","./src/components/product-import/steps/validationstep/hooks/useaivalidation/index.ts","./src/components/product-import/steps/validationstep/hooks/useaivalidation/useaiapi.ts","./src/components/product-import/steps/validationstep/hooks/useaivalidation/useaiprogress.ts","./src/components/product-import/steps/validationstep/hooks/useaivalidation/useaitransform.ts","./src/components/product-import/steps/validationstep/store/selectors.ts","./src/components/product-import/steps/validationstep/store/types.ts","./src/components/product-import/steps/validationstep/store/validationstore.ts","./src/components/product-import/steps/validationstep/utils/aivalidationutils.ts","./src/components/product-import/steps/validationstep/utils/countryutils.ts","./src/components/product-import/steps/validationstep/utils/datamutations.ts","./src/components/product-import/steps/validationstep/utils/inlineaipayload.ts","./src/components/product-import/steps/validationstep/utils/priceutils.ts","./src/components/product-import/steps/validationstep/utils/upcutils.ts","./src/components/product-import/steps/validationstepold/index.tsx","./src/components/product-import/steps/validationstepold/types.ts","./src/components/product-import/steps/validationstepold/components/aivalidationdialogs.tsx","./src/components/product-import/steps/validationstepold/components/basecellcontent.tsx","./src/components/product-import/steps/validationstepold/components/initializingvalidation.tsx","./src/components/product-import/steps/validationstepold/components/searchabletemplateselect.tsx","./src/components/product-import/steps/validationstepold/components/upcvalidationtableadapter.tsx","./src/components/product-import/steps/validationstepold/components/validationcell.tsx","./src/components/product-import/steps/validationstepold/components/validationcontainer.tsx","./src/components/product-import/steps/validationstepold/components/validationtable.tsx","./src/components/product-import/steps/validationstepold/components/cells/checkboxcell.tsx","./src/components/product-import/steps/validationstepold/components/cells/inputcell.tsx","./src/components/product-import/steps/validationstepold/components/cells/multiselectcell.tsx","./src/components/product-import/steps/validationstepold/components/cells/multilineinput.tsx","./src/components/product-import/steps/validationstepold/components/cells/selectcell.tsx","./src/components/product-import/steps/validationstepold/hooks/useaivalidation.tsx","./src/components/product-import/steps/validationstepold/hooks/usefieldvalidation.tsx","./src/components/product-import/steps/validationstepold/hooks/usefiltermanagement.tsx","./src/components/product-import/steps/validationstepold/hooks/useinitialvalidation.tsx","./src/components/product-import/steps/validationstepold/hooks/useproductlinesfetching.tsx","./src/components/product-import/steps/validationstepold/hooks/userowoperations.tsx","./src/components/product-import/steps/validationstepold/hooks/usetemplatemanagement.tsx","./src/components/product-import/steps/validationstepold/hooks/useuniqueitemnumbersvalidation.tsx","./src/components/product-import/steps/validationstepold/hooks/useuniquevalidation.tsx","./src/components/product-import/steps/validationstepold/hooks/useupcvalidation.tsx","./src/components/product-import/steps/validationstepold/hooks/usevalidation.tsx","./src/components/product-import/steps/validationstepold/hooks/usevalidationstate.tsx","./src/components/product-import/steps/validationstepold/hooks/validationtypes.ts","./src/components/product-import/steps/validationstepold/types/index.ts","./src/components/product-import/steps/validationstepold/utils/aivalidationutils.ts","./src/components/product-import/steps/validationstepold/utils/countryutils.ts","./src/components/product-import/steps/validationstepold/utils/datamutations.ts","./src/components/product-import/steps/validationstepold/utils/priceutils.ts","./src/components/product-import/steps/validationstepold/utils/upcutils.ts","./src/components/product-import/utils/exceedsmaxrecords.ts","./src/components/product-import/utils/mapdata.ts","./src/components/product-import/utils/mapworkbook.ts","./src/components/product-import/utils/steps.ts","./src/components/products/productdetail.tsx","./src/components/products/productfilters.tsx","./src/components/products/producttable.tsx","./src/components/products/producttableskeleton.tsx","./src/components/products/productviews.tsx","./src/components/products/products.tsx","./src/components/products/statusbadge.tsx","./src/components/products/columndefinitions.ts","./src/components/purchase-orders/categorymetricscard.tsx","./src/components/purchase-orders/filtercontrols.tsx","./src/components/purchase-orders/ordermetricscard.tsx","./src/components/purchase-orders/paginationcontrols.tsx","./src/components/purchase-orders/purchaseorderaccordion.tsx","./src/components/purchase-orders/purchaseorderstable.tsx","./src/components/purchase-orders/vendormetricscard.tsx","./src/components/settings/datamanagement.tsx","./src/components/settings/globalsettings.tsx","./src/components/settings/permissionselector.tsx","./src/components/settings/productsettings.tsx","./src/components/settings/promptmanagement.tsx","./src/components/settings/reusableimagemanagement.tsx","./src/components/settings/templatemanagement.tsx","./src/components/settings/userform.tsx","./src/components/settings/userlist.tsx","./src/components/settings/usermanagement.tsx","./src/components/settings/vendorsettings.tsx","./src/components/templates/searchproducttemplatedialog.tsx","./src/components/templates/templateform.tsx","./src/components/ui/accordion.tsx","./src/components/ui/alert-dialog.tsx","./src/components/ui/alert.tsx","./src/components/ui/avatar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/calendar.tsx","./src/components/ui/card.tsx","./src/components/ui/carousel.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/code.tsx","./src/components/ui/collapsible.tsx","./src/components/ui/command.tsx","./src/components/ui/date-range-picker-narrow.tsx","./src/components/ui/date-range-picker.tsx","./src/components/ui/dialog.tsx","./src/components/ui/drawer.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/form.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/page-loading.tsx","./src/components/ui/pagination.tsx","./src/components/ui/popover.tsx","./src/components/ui/progress.tsx","./src/components/ui/radio-group.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.tsx","./src/components/ui/sheet.tsx","./src/components/ui/sidebar.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/sonner.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/toast.tsx","./src/components/ui/toaster.tsx","./src/components/ui/toggle-group.tsx","./src/components/ui/toggle.tsx","./src/components/ui/tooltip.tsx","./src/config/dashboard.ts","./src/contexts/authcontext.tsx","./src/contexts/dashboardscrollcontext.tsx","./src/contexts/importsessioncontext.tsx","./src/hooks/use-mobile.tsx","./src/hooks/use-toast.ts","./src/hooks/usedebounce.ts","./src/hooks/useimportautosave.ts","./src/lib/utils.ts","./src/lib/dashboard/chartconfig.ts","./src/lib/dashboard/designtokens.ts","./src/pages/analytics.tsx","./src/pages/blackfridaydashboard.tsx","./src/pages/brands.tsx","./src/pages/categories.tsx","./src/pages/chat.tsx","./src/pages/dashboard.tsx","./src/pages/discountsimulator.tsx","./src/pages/forecasting.tsx","./src/pages/htslookup.tsx","./src/pages/import.tsx","./src/pages/login.tsx","./src/pages/newsletter.tsx","./src/pages/overview.tsx","./src/pages/producteditor.tsx","./src/pages/products.tsx","./src/pages/purchaseorders.tsx","./src/pages/settings.tsx","./src/pages/smalldashboard.tsx","./src/pages/vendors.tsx","./src/services/apiv2.ts","./src/services/importsessionapi.ts","./src/services/producteditor.ts","./src/types/dashboard-shims.d.ts","./src/types/dashboard.d.ts","./src/types/discount-simulator.ts","./src/types/globals.d.ts","./src/types/importsession.ts","./src/types/products.ts","./src/types/react-data-grid.d.ts","./src/types/status-codes.ts","./src/utils/emojiutils.ts","./src/utils/naturallanguageperiod.ts","./src/utils/productutils.ts","./src/utils/transformutils.ts"],"version":"5.6.3"} \ No newline at end of file +{"root":["./src/app.tsx","./src/config.ts","./src/main.tsx","./src/vite-env.d.ts","./src/components/config.ts","./src/components/analytics/capitalefficiency.tsx","./src/components/analytics/inventorytrends.tsx","./src/components/analytics/portfolioanalysis.tsx","./src/components/analytics/stockhealth.tsx","./src/components/auth/firstaccessiblepage.tsx","./src/components/auth/protected.tsx","./src/components/auth/requireauth.tsx","./src/components/chat/chatroom.tsx","./src/components/chat/chattest.tsx","./src/components/chat/roomlist.tsx","./src/components/chat/searchresults.tsx","./src/components/dashboard/financialoverview.tsx","./src/components/dashboard/operationsmetrics.tsx","./src/components/dashboard/payrollmetrics.tsx","./src/components/dashboard/periodselectionpopover.tsx","./src/components/dashboard/shared/dashboardbadge.tsx","./src/components/dashboard/shared/dashboardcharttooltip.tsx","./src/components/dashboard/shared/dashboardsectionheader.tsx","./src/components/dashboard/shared/dashboardskeleton.tsx","./src/components/dashboard/shared/dashboardstatcard.tsx","./src/components/dashboard/shared/dashboardstatcardmini.tsx","./src/components/dashboard/shared/dashboardstates.tsx","./src/components/dashboard/shared/dashboardtable.tsx","./src/components/dashboard/shared/index.ts","./src/components/discount-simulator/configpanel.tsx","./src/components/discount-simulator/resultschart.tsx","./src/components/discount-simulator/resultstable.tsx","./src/components/discount-simulator/summarycard.tsx","./src/components/forecasting/daterangepickerquick.tsx","./src/components/forecasting/quickorderbuilder.tsx","./src/components/forecasting/columns.tsx","./src/components/layout/appsidebar.tsx","./src/components/layout/mainlayout.tsx","./src/components/layout/navuser.tsx","./src/components/newsletter/campaignhistorydialog.tsx","./src/components/newsletter/newsletterstats.tsx","./src/components/newsletter/recommendationtable.tsx","./src/components/overview/bestsellers.tsx","./src/components/overview/forecastmetrics.tsx","./src/components/overview/overstockmetrics.tsx","./src/components/overview/overview.tsx","./src/components/overview/purchasemetrics.tsx","./src/components/overview/replenishmentmetrics.tsx","./src/components/overview/salesmetrics.tsx","./src/components/overview/stockmetrics.tsx","./src/components/overview/topoverstockedproducts.tsx","./src/components/overview/topreplenishproducts.tsx","./src/components/overview/vendorperformance.tsx","./src/components/product-editor/comboboxfield.tsx","./src/components/product-editor/editablecomboboxfield.tsx","./src/components/product-editor/editableinput.tsx","./src/components/product-editor/editablemultiselect.tsx","./src/components/product-editor/imagemanager.tsx","./src/components/product-editor/producteditform.tsx","./src/components/product-editor/productsearch.tsx","./src/components/product-editor/types.ts","./src/components/product-import/createproductcategorydialog.tsx","./src/components/product-import/reactspreadsheetimport.tsx","./src/components/product-import/config.ts","./src/components/product-import/index.ts","./src/components/product-import/translationsrsiprops.ts","./src/components/product-import/types.ts","./src/components/product-import/components/closeconfirmationdialog.tsx","./src/components/product-import/components/modalwrapper.tsx","./src/components/product-import/components/providers.tsx","./src/components/product-import/components/savesessiondialog.tsx","./src/components/product-import/components/savedsessionslist.tsx","./src/components/product-import/components/table.tsx","./src/components/product-import/hooks/usersi.ts","./src/components/product-import/steps/steps.tsx","./src/components/product-import/steps/uploadflow.tsx","./src/components/product-import/steps/imageuploadstep/imageuploadstep.tsx","./src/components/product-import/steps/imageuploadstep/types.ts","./src/components/product-import/steps/imageuploadstep/components/droppablecontainer.tsx","./src/components/product-import/steps/imageuploadstep/components/genericdropzone.tsx","./src/components/product-import/steps/imageuploadstep/components/unassignedimagessection.tsx","./src/components/product-import/steps/imageuploadstep/components/productcard/copybutton.tsx","./src/components/product-import/steps/imageuploadstep/components/productcard/imagedropzone.tsx","./src/components/product-import/steps/imageuploadstep/components/productcard/productcard.tsx","./src/components/product-import/steps/imageuploadstep/components/productcard/sortableimage.tsx","./src/components/product-import/steps/imageuploadstep/components/unassignedimagessection/unassignedimageitem.tsx","./src/components/product-import/steps/imageuploadstep/hooks/usebulkimageupload.ts","./src/components/product-import/steps/imageuploadstep/hooks/usedraganddrop.ts","./src/components/product-import/steps/imageuploadstep/hooks/useproductimageoperations.ts","./src/components/product-import/steps/imageuploadstep/hooks/useproductimagesinit.ts","./src/components/product-import/steps/imageuploadstep/hooks/useurlimageupload.ts","./src/components/product-import/steps/matchcolumnsstep/matchcolumnsstep.tsx","./src/components/product-import/steps/matchcolumnsstep/types.ts","./src/components/product-import/steps/matchcolumnsstep/components/matchicon.tsx","./src/components/product-import/steps/matchcolumnsstep/components/templatecolumn.tsx","./src/components/product-import/steps/matchcolumnsstep/utils/findmatch.ts","./src/components/product-import/steps/matchcolumnsstep/utils/findunmatchedrequiredfields.ts","./src/components/product-import/steps/matchcolumnsstep/utils/getfieldoptions.ts","./src/components/product-import/steps/matchcolumnsstep/utils/getmatchedcolumns.ts","./src/components/product-import/steps/matchcolumnsstep/utils/normalizecheckboxvalue.ts","./src/components/product-import/steps/matchcolumnsstep/utils/normalizetabledata.ts","./src/components/product-import/steps/matchcolumnsstep/utils/setcolumn.ts","./src/components/product-import/steps/matchcolumnsstep/utils/setignorecolumn.ts","./src/components/product-import/steps/matchcolumnsstep/utils/setsubcolumn.ts","./src/components/product-import/steps/matchcolumnsstep/utils/uniqueentries.ts","./src/components/product-import/steps/selectheaderstep/selectheaderstep.tsx","./src/components/product-import/steps/selectheaderstep/components/selectheadertable.tsx","./src/components/product-import/steps/selectheaderstep/components/columns.tsx","./src/components/product-import/steps/selectsheetstep/selectsheetstep.tsx","./src/components/product-import/steps/uploadstep/uploadstep.tsx","./src/components/product-import/steps/uploadstep/components/dropzone.tsx","./src/components/product-import/steps/uploadstep/components/columns.tsx","./src/components/product-import/steps/uploadstep/utils/readfilesasync.ts","./src/components/product-import/steps/validationstep/index.tsx","./src/components/product-import/steps/validationstep/components/aisuggestionbadge.tsx","./src/components/product-import/steps/validationstep/components/copydownbanner.tsx","./src/components/product-import/steps/validationstep/components/floatingselectionbar.tsx","./src/components/product-import/steps/validationstep/components/initializingoverlay.tsx","./src/components/product-import/steps/validationstep/components/searchabletemplateselect.tsx","./src/components/product-import/steps/validationstep/components/suggestionbadges.tsx","./src/components/product-import/steps/validationstep/components/validationcontainer.tsx","./src/components/product-import/steps/validationstep/components/validationfooter.tsx","./src/components/product-import/steps/validationstep/components/validationtable.tsx","./src/components/product-import/steps/validationstep/components/validationtoolbar.tsx","./src/components/product-import/steps/validationstep/components/cells/checkboxcell.tsx","./src/components/product-import/steps/validationstep/components/cells/comboboxcell.tsx","./src/components/product-import/steps/validationstep/components/cells/inputcell.tsx","./src/components/product-import/steps/validationstep/components/cells/multiselectcell.tsx","./src/components/product-import/steps/validationstep/components/cells/multilineinput.tsx","./src/components/product-import/steps/validationstep/components/cells/selectcell.tsx","./src/components/product-import/steps/validationstep/contexts/aisuggestionscontext.tsx","./src/components/product-import/steps/validationstep/dialogs/aidebugdialog.tsx","./src/components/product-import/steps/validationstep/dialogs/aivalidationprogress.tsx","./src/components/product-import/steps/validationstep/dialogs/aivalidationresults.tsx","./src/components/product-import/steps/validationstep/dialogs/sanitycheckdialog.tsx","./src/components/product-import/steps/validationstep/hooks/useautoinlineaivalidation.ts","./src/components/product-import/steps/validationstep/hooks/usecopydownvalidation.ts","./src/components/product-import/steps/validationstep/hooks/usefieldoptions.ts","./src/components/product-import/steps/validationstep/hooks/useinlineaivalidation.ts","./src/components/product-import/steps/validationstep/hooks/useproductlines.ts","./src/components/product-import/steps/validationstep/hooks/usesanitycheck.ts","./src/components/product-import/steps/validationstep/hooks/usetemplatemanagement.ts","./src/components/product-import/steps/validationstep/hooks/useupcvalidation.ts","./src/components/product-import/steps/validationstep/hooks/usevalidationactions.ts","./src/components/product-import/steps/validationstep/hooks/useaivalidation/index.ts","./src/components/product-import/steps/validationstep/hooks/useaivalidation/useaiapi.ts","./src/components/product-import/steps/validationstep/hooks/useaivalidation/useaiprogress.ts","./src/components/product-import/steps/validationstep/hooks/useaivalidation/useaitransform.ts","./src/components/product-import/steps/validationstep/store/selectors.ts","./src/components/product-import/steps/validationstep/store/types.ts","./src/components/product-import/steps/validationstep/store/validationstore.ts","./src/components/product-import/steps/validationstep/utils/aivalidationutils.ts","./src/components/product-import/steps/validationstep/utils/countryutils.ts","./src/components/product-import/steps/validationstep/utils/datamutations.ts","./src/components/product-import/steps/validationstep/utils/inlineaipayload.ts","./src/components/product-import/steps/validationstep/utils/priceutils.ts","./src/components/product-import/steps/validationstep/utils/upcutils.ts","./src/components/product-import/steps/validationstepold/index.tsx","./src/components/product-import/steps/validationstepold/types.ts","./src/components/product-import/steps/validationstepold/components/aivalidationdialogs.tsx","./src/components/product-import/steps/validationstepold/components/basecellcontent.tsx","./src/components/product-import/steps/validationstepold/components/initializingvalidation.tsx","./src/components/product-import/steps/validationstepold/components/searchabletemplateselect.tsx","./src/components/product-import/steps/validationstepold/components/upcvalidationtableadapter.tsx","./src/components/product-import/steps/validationstepold/components/validationcell.tsx","./src/components/product-import/steps/validationstepold/components/validationcontainer.tsx","./src/components/product-import/steps/validationstepold/components/validationtable.tsx","./src/components/product-import/steps/validationstepold/components/cells/checkboxcell.tsx","./src/components/product-import/steps/validationstepold/components/cells/inputcell.tsx","./src/components/product-import/steps/validationstepold/components/cells/multiselectcell.tsx","./src/components/product-import/steps/validationstepold/components/cells/multilineinput.tsx","./src/components/product-import/steps/validationstepold/components/cells/selectcell.tsx","./src/components/product-import/steps/validationstepold/hooks/useaivalidation.tsx","./src/components/product-import/steps/validationstepold/hooks/usefieldvalidation.tsx","./src/components/product-import/steps/validationstepold/hooks/usefiltermanagement.tsx","./src/components/product-import/steps/validationstepold/hooks/useinitialvalidation.tsx","./src/components/product-import/steps/validationstepold/hooks/useproductlinesfetching.tsx","./src/components/product-import/steps/validationstepold/hooks/userowoperations.tsx","./src/components/product-import/steps/validationstepold/hooks/usetemplatemanagement.tsx","./src/components/product-import/steps/validationstepold/hooks/useuniqueitemnumbersvalidation.tsx","./src/components/product-import/steps/validationstepold/hooks/useuniquevalidation.tsx","./src/components/product-import/steps/validationstepold/hooks/useupcvalidation.tsx","./src/components/product-import/steps/validationstepold/hooks/usevalidation.tsx","./src/components/product-import/steps/validationstepold/hooks/usevalidationstate.tsx","./src/components/product-import/steps/validationstepold/hooks/validationtypes.ts","./src/components/product-import/steps/validationstepold/types/index.ts","./src/components/product-import/steps/validationstepold/utils/aivalidationutils.ts","./src/components/product-import/steps/validationstepold/utils/countryutils.ts","./src/components/product-import/steps/validationstepold/utils/datamutations.ts","./src/components/product-import/steps/validationstepold/utils/priceutils.ts","./src/components/product-import/steps/validationstepold/utils/upcutils.ts","./src/components/product-import/utils/exceedsmaxrecords.ts","./src/components/product-import/utils/mapdata.ts","./src/components/product-import/utils/mapworkbook.ts","./src/components/product-import/utils/steps.ts","./src/components/products/productdetail.tsx","./src/components/products/productfilters.tsx","./src/components/products/productsummarycards.tsx","./src/components/products/producttable.tsx","./src/components/products/producttableskeleton.tsx","./src/components/products/productviews.tsx","./src/components/products/statusbadge.tsx","./src/components/products/columndefinitions.ts","./src/components/purchase-orders/categorymetricscard.tsx","./src/components/purchase-orders/filtercontrols.tsx","./src/components/purchase-orders/ordermetricscard.tsx","./src/components/purchase-orders/paginationcontrols.tsx","./src/components/purchase-orders/purchaseorderaccordion.tsx","./src/components/purchase-orders/purchaseorderstable.tsx","./src/components/purchase-orders/vendormetricscard.tsx","./src/components/settings/datamanagement.tsx","./src/components/settings/globalsettings.tsx","./src/components/settings/permissionselector.tsx","./src/components/settings/productsettings.tsx","./src/components/settings/promptmanagement.tsx","./src/components/settings/reusableimagemanagement.tsx","./src/components/settings/templatemanagement.tsx","./src/components/settings/userform.tsx","./src/components/settings/userlist.tsx","./src/components/settings/usermanagement.tsx","./src/components/settings/vendorsettings.tsx","./src/components/templates/searchproducttemplatedialog.tsx","./src/components/templates/templateform.tsx","./src/components/ui/accordion.tsx","./src/components/ui/alert-dialog.tsx","./src/components/ui/alert.tsx","./src/components/ui/avatar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/calendar.tsx","./src/components/ui/card.tsx","./src/components/ui/carousel.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/code.tsx","./src/components/ui/collapsible.tsx","./src/components/ui/command.tsx","./src/components/ui/date-range-picker-narrow.tsx","./src/components/ui/date-range-picker.tsx","./src/components/ui/dialog.tsx","./src/components/ui/drawer.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/form.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/page-loading.tsx","./src/components/ui/pagination.tsx","./src/components/ui/popover.tsx","./src/components/ui/progress.tsx","./src/components/ui/radio-group.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.tsx","./src/components/ui/sheet.tsx","./src/components/ui/sidebar.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/sonner.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/toast.tsx","./src/components/ui/toaster.tsx","./src/components/ui/toggle-group.tsx","./src/components/ui/toggle.tsx","./src/components/ui/tooltip.tsx","./src/config/dashboard.ts","./src/contexts/authcontext.tsx","./src/contexts/dashboardscrollcontext.tsx","./src/contexts/importsessioncontext.tsx","./src/hooks/use-mobile.tsx","./src/hooks/use-toast.ts","./src/hooks/usedebounce.ts","./src/hooks/useimportautosave.ts","./src/lib/utils.ts","./src/lib/dashboard/chartconfig.ts","./src/lib/dashboard/designtokens.ts","./src/pages/analytics.tsx","./src/pages/blackfridaydashboard.tsx","./src/pages/brands.tsx","./src/pages/categories.tsx","./src/pages/chat.tsx","./src/pages/dashboard.tsx","./src/pages/discountsimulator.tsx","./src/pages/forecasting.tsx","./src/pages/htslookup.tsx","./src/pages/import.tsx","./src/pages/login.tsx","./src/pages/newsletter.tsx","./src/pages/overview.tsx","./src/pages/producteditor.tsx","./src/pages/products.tsx","./src/pages/purchaseorders.tsx","./src/pages/settings.tsx","./src/pages/smalldashboard.tsx","./src/pages/vendors.tsx","./src/services/apiv2.ts","./src/services/importsessionapi.ts","./src/services/producteditor.ts","./src/types/dashboard-shims.d.ts","./src/types/dashboard.d.ts","./src/types/discount-simulator.ts","./src/types/globals.d.ts","./src/types/importsession.ts","./src/types/products.ts","./src/types/react-data-grid.d.ts","./src/types/status-codes.ts","./src/utils/emojiutils.ts","./src/utils/naturallanguageperiod.ts","./src/utils/productutils.ts","./src/utils/transformutils.ts"],"version":"5.6.3"} \ No newline at end of file