From f41b5ab0f61dc15ea387a65442508f682ec9e22c Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 9 Feb 2026 22:59:34 -0500 Subject: [PATCH] Clean up inventory overview page --- .../metrics-new/update_product_metrics.sql | 90 +- inventory-server/src/routes/analytics.js | 114 ++- inventory-server/src/routes/dashboard.js | 893 +++--------------- .../src/routes/purchase-orders.js | 73 +- .../analytics/CapitalEfficiency.tsx | 28 +- .../components/analytics/GrowthMomentum.tsx | 78 +- .../src/components/analytics/StockoutRisk.tsx | 2 +- .../src/components/overview/BestSellers.tsx | 233 ++--- .../components/overview/ForecastMetrics.tsx | 2 +- .../components/overview/OverstockMetrics.tsx | 78 +- .../src/components/overview/Overview.tsx | 66 -- .../components/overview/PurchaseMetrics.tsx | 232 +++-- .../overview/ReplenishmentMetrics.tsx | 73 +- .../src/components/overview/SalesMetrics.tsx | 164 ++-- .../src/components/overview/StockMetrics.tsx | 216 +++-- .../overview/TopOverstockedProducts.tsx | 92 +- .../overview/TopReplenishProducts.tsx | 91 +- .../components/overview/VendorPerformance.tsx | 79 -- inventory/src/pages/Analytics.tsx | 2 +- 19 files changed, 1064 insertions(+), 1542 deletions(-) delete mode 100644 inventory/src/components/overview/Overview.tsx delete mode 100644 inventory/src/components/overview/VendorPerformance.tsx diff --git a/inventory-server/scripts/metrics-new/update_product_metrics.sql b/inventory-server/scripts/metrics-new/update_product_metrics.sql index 7a8f05a..421c0dc 100644 --- a/inventory-server/scripts/metrics-new/update_product_metrics.sql +++ b/inventory-server/scripts/metrics-new/update_product_metrics.sql @@ -61,16 +61,72 @@ BEGIN p.uom -- Assuming UOM logic is handled elsewhere or simple (e.g., 1=each) FROM public.products p ), + -- Stale POs: open, >90 days past expected, AND a newer PO exists for the same product. + -- These are likely abandoned/superseded and should not consume receivings in FIFO. + StalePOLines AS ( + SELECT po.po_id, po.pid + FROM public.purchase_orders po + WHERE po.status IN ('created', 'ordered', 'preordered', 'electronically_sent', + 'electronically_ready_send', 'receiving_started') + AND po.expected_date IS NOT NULL + AND po.expected_date < _current_date - INTERVAL '90 days' + AND EXISTS ( + SELECT 1 FROM public.purchase_orders newer + WHERE newer.pid = po.pid + AND newer.status NOT IN ('canceled', 'done') + AND COALESCE(newer.date_ordered, newer.date_created) + > COALESCE(po.date_ordered, po.date_created) + ) + ), + -- All non-canceled, non-stale POs in FIFO order per (pid, supplier). + -- Includes closed ('done') POs so they consume receivings before open POs. + POFifo AS ( + SELECT + po.pid, po.supplier_id, po.po_id, po.ordered, po.status, + po.po_cost_price, po.expected_date, + SUM(po.ordered) OVER ( + PARTITION BY po.pid, po.supplier_id + ORDER BY COALESCE(po.date_ordered, po.date_created), po.po_id + ) - po.ordered AS cumulative_before + FROM public.purchase_orders po + WHERE po.status != 'canceled' + AND NOT EXISTS ( + SELECT 1 FROM StalePOLines s + WHERE s.po_id = po.po_id AND s.pid = po.pid + ) + ), + -- Total received per (product, supplier) across all receivings. + SupplierReceived AS ( + SELECT pid, supplier_id, SUM(qty_each) AS total_received + FROM public.receivings + WHERE status IN ('partial_received', 'full_received', 'paid') + GROUP BY pid, supplier_id + ), + -- FIFO allocation: receivings fill oldest POs first per (pid, supplier). + -- Only open PO lines are reported; closed POs just absorb receivings. OnOrderInfo AS ( SELECT - pid, - SUM(ordered) AS on_order_qty, - SUM(ordered * po_cost_price) AS on_order_cost, - MIN(expected_date) AS earliest_expected_date - FROM public.purchase_orders - WHERE status IN ('created', 'ordered', 'preordered', 'electronically_sent', 'electronically_ready_send', 'receiving_started') - AND status NOT IN ('canceled', 'done') - GROUP BY pid + po.pid, + SUM(GREATEST(0, + po.ordered - GREATEST(0, LEAST(po.ordered, + COALESCE(sr.total_received, 0) - po.cumulative_before + )) + )) AS on_order_qty, + SUM(GREATEST(0, + po.ordered - GREATEST(0, LEAST(po.ordered, + COALESCE(sr.total_received, 0) - po.cumulative_before + )) + ) * po.po_cost_price) AS on_order_cost, + MIN(po.expected_date) FILTER (WHERE + po.ordered > GREATEST(0, LEAST(po.ordered, + COALESCE(sr.total_received, 0) - po.cumulative_before + )) + ) AS earliest_expected_date + FROM POFifo po + LEFT JOIN SupplierReceived sr ON sr.pid = po.pid AND sr.supplier_id = po.supplier_id + WHERE po.status IN ('created', 'ordered', 'preordered', 'electronically_sent', + 'electronically_ready_send', 'receiving_started') + GROUP BY po.pid ), HistoricalDates AS ( -- Note: Calculating these MIN/MAX values hourly can be slow on large tables. @@ -142,6 +198,17 @@ BEGIN FROM public.daily_product_snapshots GROUP BY pid ), + BeginningStock AS ( + -- Get stock level from 30 days ago for sell-through calculation. + -- Uses the closest available snapshot if exact date is missing (activity-only snapshots). + SELECT DISTINCT ON (pid) + pid, + eod_stock_quantity AS beginning_stock_30d + FROM public.daily_product_snapshots + WHERE snapshot_date <= _current_date - INTERVAL '30 days' + AND snapshot_date >= _current_date - INTERVAL '37 days' + ORDER BY pid, snapshot_date DESC + ), FirstPeriodMetrics AS ( SELECT pid, @@ -403,10 +470,10 @@ BEGIN (sa.stockout_days_30d / 30.0) * 100 AS stockout_rate_30d, sa.gross_regular_revenue_30d - sa.gross_revenue_30d AS markdown_30d, ((sa.gross_regular_revenue_30d - sa.gross_revenue_30d) / NULLIF(sa.gross_regular_revenue_30d, 0)) * 100 AS markdown_rate_30d, - -- Fix sell-through rate: Industry standard is Units Sold / (Beginning Inventory + Units Received) - -- Approximating beginning inventory as current stock + units sold - units received + -- Sell-through rate: Industry standard is Units Sold / (Beginning Inventory + Units Received) + -- Uses actual snapshot from 30 days ago as beginning stock, falls back to avg_stock_units_30d (sa.sales_30d / NULLIF( - ci.current_stock + sa.sales_30d + sa.returns_units_30d - sa.received_qty_30d, + COALESCE(bs.beginning_stock_30d, sa.avg_stock_units_30d::int, 0) + sa.received_qty_30d, 0 )) * 100 AS sell_through_30d, @@ -555,6 +622,7 @@ BEGIN LEFT JOIN PreviousPeriodMetrics ppm ON ci.pid = ppm.pid LEFT JOIN DemandVariability dv ON ci.pid = dv.pid LEFT JOIN ServiceLevels sl ON ci.pid = sl.pid + LEFT JOIN BeginningStock bs ON ci.pid = bs.pid LEFT JOIN SeasonalityAnalysis season ON ci.pid = season.pid WHERE s.exclude_forecast IS FALSE OR s.exclude_forecast IS NULL -- Exclude products explicitly marked diff --git a/inventory-server/src/routes/analytics.js b/inventory-server/src/routes/analytics.js index 864b07f..b233a2c 100644 --- a/inventory-server/src/routes/analytics.js +++ b/inventory-server/src/routes/analytics.js @@ -171,30 +171,37 @@ router.get('/inventory-summary', async (req, res) => { const pool = req.app.locals.pool; const { rows: [summary] } = await pool.query(` - SELECT - SUM(current_stock_cost) AS stock_investment, - SUM(on_order_cost) AS on_order_value, - CASE - 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 SUM(avg_stock_cost_30d) > 0 - THEN (SUM(profit_30d) / SUM(avg_stock_cost_30d)) * 12 - 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 + WITH agg AS ( + SELECT + SUM(current_stock_cost) AS stock_investment, + SUM(on_order_cost) AS on_order_value, + CASE + 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 SUM(avg_stock_cost_30d) > 0 + THEN (SUM(profit_30d) / SUM(avg_stock_cost_30d)) * 12 + ELSE 0 + END AS gmroi, + COUNT(*) FILTER (WHERE current_stock > 0) AS products_in_stock, + COUNT(*) FILTER (WHERE is_old_stock = true AND current_stock > 0) AS dead_stock_products, + SUM(CASE WHEN is_old_stock = true AND current_stock > 0 THEN current_stock_cost ELSE 0 END) AS dead_stock_value + FROM product_metrics + WHERE is_visible = true + ), + cover AS ( + SELECT + PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY stock_cover_in_days) AS median_stock_cover_days + FROM product_metrics + WHERE is_visible = true + AND current_stock > 0 + AND sales_velocity_daily > 0 + AND stock_cover_in_days IS NOT NULL + ) + SELECT agg.*, cover.median_stock_cover_days + FROM agg, cover `); res.json({ @@ -202,7 +209,7 @@ router.get('/inventory-summary', async (req, res) => { 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, + avgStockCoverDays: Number(summary.median_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, @@ -266,9 +273,9 @@ router.get('/portfolio', async (req, res) => { // 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 is_old_stock = true AND current_stock > 0) AS dead_stock_count, + SUM(CASE WHEN is_old_stock = true AND current_stock > 0 THEN current_stock_cost ELSE 0 END) AS dead_stock_cost, + SUM(CASE WHEN is_old_stock = true AND current_stock > 0 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 @@ -300,14 +307,14 @@ router.get('/portfolio', async (req, res) => { } }); -// Capital efficiency — GMROI by vendor (single combined query) +// Capital efficiency — GMROI by brand (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, + COALESCE(brand, 'Unbranded') AS brand_name, COUNT(*) AS product_count, SUM(current_stock_cost) AS stock_cost, SUM(profit_30d) AS profit_30d, @@ -319,17 +326,17 @@ router.get('/efficiency', async (req, res) => { END AS gmroi FROM product_metrics WHERE is_visible = true - AND vendor IS NOT NULL + AND brand IS NOT NULL AND current_stock_cost > 0 - GROUP BY vendor + GROUP BY brand 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, + brands: rows.map(r => ({ + brand: r.brand_name, productCount: Number(r.product_count) || 0, stockCost: Number(r.stock_cost) || 0, profit30d: Number(r.profit_30d) || 0, @@ -527,7 +534,7 @@ router.get('/stockout-risk', async (req, res) => { const { rows } = await pool.query(` WITH base AS ( SELECT - title, sku, vendor, + title, sku, brand, ${leadTimeSql} AS lead_time_days, sells_out_in_days, current_stock, sales_velocity_daily, revenue_30d, abc_class @@ -554,7 +561,7 @@ router.get('/stockout-risk', async (req, res) => { products: rows.map(r => ({ title: r.title, sku: r.sku, - vendor: r.vendor, + brand: r.brand, leadTimeDays: Number(r.lead_time_days) || 0, sellsOutInDays: Number(r.sells_out_in_days) || 0, currentStock: Number(r.current_stock) || 0, @@ -624,6 +631,7 @@ router.get('/growth', async (req, res) => { try { const pool = req.app.locals.pool; + // ABC breakdown — only "comparable" products (sold in BOTH periods, i.e. growth != -100%) const { rows } = await pool.query(` SELECT COALESCE(abc_class, 'N/A') AS abc_class, @@ -645,21 +653,39 @@ router.get('/growth', async (req, res) => { FROM product_metrics WHERE is_visible = true AND sales_growth_yoy IS NOT NULL + AND sales_30d > 0 GROUP BY 1, 2, 3 ORDER BY abc_class, sort_order `); - // Summary stats + // Summary: comparable products (sold in both periods) with revenue-weighted avg const { rows: [summary] } = await pool.query(` SELECT - COUNT(*) AS total_with_yoy, + COUNT(*) AS comparable_count, 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( + CASE WHEN SUM(revenue_30d) > 0 + THEN SUM(sales_growth_yoy * revenue_30d) / SUM(revenue_30d) + ELSE 0 + END::numeric, 1 + ) AS weighted_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 + AND sales_30d > 0 + `); + + // Catalog turnover: new products (selling now, no sales last year) and discontinued (sold last year, not now) + const { rows: [turnover] } = await pool.query(` + SELECT + COUNT(*) FILTER (WHERE sales_growth_yoy IS NULL AND sales_30d > 0 AND age_days < 365) AS new_products, + SUM(revenue_30d) FILTER (WHERE sales_growth_yoy IS NULL AND sales_30d > 0 AND age_days < 365) AS new_product_revenue, + COUNT(*) FILTER (WHERE sales_growth_yoy = -100) AS discontinued, + SUM(current_stock_cost) FILTER (WHERE sales_growth_yoy = -100 AND current_stock > 0) AS discontinued_stock_value + FROM product_metrics + WHERE is_visible = true `); res.json({ @@ -671,12 +697,18 @@ router.get('/growth', async (req, res) => { stockCost: Number(r.stock_cost) || 0, })), summary: { - totalWithYoy: Number(summary.total_with_yoy) || 0, + comparableCount: Number(summary.comparable_count) || 0, growingCount: Number(summary.growing_count) || 0, decliningCount: Number(summary.declining_count) || 0, - avgGrowth: Number(summary.avg_growth) || 0, + weightedAvgGrowth: Number(summary.weighted_avg_growth) || 0, medianGrowth: Number(summary.median_growth) || 0, }, + turnover: { + newProducts: Number(turnover.new_products) || 0, + newProductRevenue: Number(turnover.new_product_revenue) || 0, + discontinued: Number(turnover.discontinued) || 0, + discontinuedStockValue: Number(turnover.discontinued_stock_value) || 0, + }, }); } catch (error) { console.error('Error fetching growth data:', error); diff --git a/inventory-server/src/routes/dashboard.js b/inventory-server/src/routes/dashboard.js index 3a21c19..7a9c341 100644 --- a/inventory-server/src/routes/dashboard.js +++ b/inventory-server/src/routes/dashboard.js @@ -2,9 +2,6 @@ const express = require('express'); const router = express.Router(); const db = require('../utils/db'); -// Import status codes -const { ReceivingStatus } = require('../types/status-codes'); - // Helper function to execute queries using the connection pool async function executeQuery(sql, params = []) { const pool = db.getPool(); @@ -27,19 +24,13 @@ router.get('/stock/metrics', async (req, res) => { ROUND(COALESCE(SUM(CASE WHEN current_stock > 0 THEN current_stock_cost END), 0)::numeric, 3) as total_cost, ROUND(COALESCE(SUM(CASE WHEN current_stock > 0 THEN current_stock_retail END), 0)::numeric, 3) as total_retail FROM product_metrics + WHERE is_visible = true `); - console.log('Raw stockMetrics from database:', stockMetrics); - console.log('stockMetrics.total_products:', stockMetrics.total_products); - console.log('stockMetrics.products_in_stock:', stockMetrics.products_in_stock); - console.log('stockMetrics.total_units:', stockMetrics.total_units); - console.log('stockMetrics.total_cost:', stockMetrics.total_cost); - console.log('stockMetrics.total_retail:', stockMetrics.total_retail); - // Get brand stock values with Other category const { rows: brandValues } = await executeQuery(` WITH brand_totals AS ( - SELECT + SELECT COALESCE(brand, 'Unbranded') as brand, COUNT(DISTINCT pid)::integer as variant_count, COALESCE(SUM(current_stock), 0)::integer as stock_units, @@ -47,6 +38,7 @@ router.get('/stock/metrics', async (req, res) => { ROUND(COALESCE(SUM(current_stock_retail), 0)::numeric, 3) as stock_retail FROM product_metrics WHERE current_stock > 0 + AND is_visible = true GROUP BY COALESCE(brand, 'Unbranded') HAVING ROUND(COALESCE(SUM(current_stock_cost), 0)::numeric, 3) > 0 ), @@ -102,122 +94,72 @@ router.get('/stock/metrics', async (req, res) => { // Returns purchase order metrics by vendor router.get('/purchase/metrics', async (req, res) => { try { - // First check if there are any purchase orders in the database - const { rows: [poCount] } = await executeQuery(` - SELECT COUNT(*) as count FROM purchase_orders - `); - - const { rows: [poMetrics] } = await executeQuery(` - WITH po_metrics AS ( - SELECT - po_id, - status, - date, - expected_date, - pid, - ordered, - po_cost_price + // Active/overdue PO counts (staleness-filtered, from purchase_orders directly) + const activePOs = await executeQuery(` + WITH stale AS ( + SELECT po_id, pid FROM purchase_orders po - WHERE po.status NOT IN ('canceled', 'done') - AND po.date >= CURRENT_DATE - INTERVAL '6 months' + WHERE po.status IN ('created', 'ordered', 'preordered', 'electronically_sent', + 'electronically_ready_send', 'receiving_started') + AND po.expected_date IS NOT NULL + AND po.expected_date < CURRENT_DATE - INTERVAL '90 days' + AND EXISTS ( + SELECT 1 FROM purchase_orders newer + WHERE newer.pid = po.pid + AND newer.status NOT IN ('canceled', 'done') + AND COALESCE(newer.date_ordered, newer.date_created) + > COALESCE(po.date_ordered, po.date_created) + ) ) - SELECT - COUNT(DISTINCT po_id)::integer as active_pos, - COUNT(DISTINCT CASE WHEN expected_date < CURRENT_DATE THEN po_id END)::integer as overdue_pos, - SUM(ordered)::integer as total_units, - ROUND(SUM(ordered * po_cost_price)::numeric, 3) as total_cost, - ROUND(SUM(ordered * pm.current_price)::numeric, 3) as total_retail - FROM po_metrics po - JOIN product_metrics pm ON po.pid = pm.pid + SELECT + COUNT(DISTINCT po_id)::integer AS active_pos, + COUNT(DISTINCT CASE WHEN expected_date < CURRENT_DATE THEN po_id END)::integer AS overdue_pos + FROM purchase_orders po + WHERE po.status NOT IN ('canceled', 'done') + AND NOT EXISTS (SELECT 1 FROM stale s WHERE s.po_id = po.po_id AND s.pid = po.pid) `); + const poMetrics = activePOs.rows[0]; - const { rows: vendorOrders } = await executeQuery(` - WITH po_by_vendor AS ( - SELECT - vendor, - po_id, - SUM(ordered) as total_ordered, - SUM(ordered * po_cost_price) as total_cost - FROM purchase_orders - WHERE status NOT IN ('canceled', 'done') - AND date >= CURRENT_DATE - INTERVAL '6 months' - GROUP BY vendor, po_id - ) - SELECT - pv.vendor, - COUNT(DISTINCT pv.po_id)::integer as orders, - SUM(pv.total_ordered)::integer as units, - ROUND(SUM(pv.total_cost)::numeric, 3) as cost, - ROUND(SUM(pv.total_ordered * pm.current_price)::numeric, 3) as retail - FROM po_by_vendor pv - JOIN purchase_orders po ON pv.po_id = po.po_id - JOIN product_metrics pm ON po.pid = pm.pid - GROUP BY pv.vendor - HAVING ROUND(SUM(pv.total_cost)::numeric, 3) > 0 + // On-order totals and vendor breakdown from product_metrics (FIFO-computed) + // Consistent with Analytics and Pipeline components + const { rows: vendorRows } = await executeQuery(` + SELECT + vendor, + SUM(on_order_qty)::integer AS units, + ROUND(SUM(on_order_cost)::numeric, 2) AS cost, + ROUND(SUM(on_order_retail)::numeric, 2) AS retail + FROM product_metrics + WHERE is_visible = true AND on_order_qty > 0 + GROUP BY vendor ORDER BY cost DESC `); - // If no purchase orders exist at all in the database, return dummy data - if (parseInt(poCount.count) === 0) { - console.log('No purchase orders found in database, returning dummy data'); - - return res.json({ - activePurchaseOrders: 12, - overduePurchaseOrders: 3, - onOrderUnits: 1250, - onOrderCost: 12500, - onOrderRetail: 25000, - vendorOrders: [ - { vendor: "Test Vendor 1", orders: 5, units: 500, cost: 5000, retail: 10000 }, - { vendor: "Test Vendor 2", orders: 4, units: 400, cost: 4000, retail: 8000 }, - { vendor: "Test Vendor 3", orders: 3, units: 350, cost: 3500, retail: 7000 } - ] - }); - } - - // If no active purchase orders match the criteria, return zeros instead of dummy data - if (vendorOrders.length === 0) { - console.log('No active purchase orders matching criteria, returning zeros'); - - return res.json({ - activePurchaseOrders: parseInt(poMetrics.active_pos) || 0, - overduePurchaseOrders: parseInt(poMetrics.overdue_pos) || 0, - onOrderUnits: parseInt(poMetrics.total_units) || 0, - onOrderCost: parseFloat(poMetrics.total_cost) || 0, - onOrderRetail: parseFloat(poMetrics.total_retail) || 0, - vendorOrders: [] - }); - } + const vendorOrders = vendorRows.map(v => ({ + vendor: v.vendor, + orders: 0, + units: parseInt(v.units) || 0, + cost: parseFloat(v.cost) || 0, + retail: parseFloat(v.retail) || 0 + })); + + const onOrderUnits = vendorOrders.reduce((sum, v) => sum + v.units, 0); + const onOrderCost = vendorOrders.reduce((sum, v) => sum + v.cost, 0); + const onOrderRetail = vendorOrders.reduce((sum, v) => sum + v.retail, 0); // Format response to match PurchaseMetricsData interface const response = { activePurchaseOrders: parseInt(poMetrics.active_pos) || 0, overduePurchaseOrders: parseInt(poMetrics.overdue_pos) || 0, - onOrderUnits: parseInt(poMetrics.total_units) || 0, - onOrderCost: parseFloat(poMetrics.total_cost) || 0, - onOrderRetail: parseFloat(poMetrics.total_retail) || 0, - vendorOrders: vendorOrders.map(v => ({ - vendor: v.vendor, - orders: parseInt(v.orders) || 0, - units: parseInt(v.units) || 0, - cost: parseFloat(v.cost) || 0, - retail: parseFloat(v.retail) || 0 - })) + onOrderUnits, + onOrderCost, + onOrderRetail, + vendorOrders }; res.json(response); } catch (err) { console.error('Error fetching purchase metrics:', err); - res.status(500).json({ - error: 'Failed to fetch purchase metrics', - details: err.message, - activePurchaseOrders: 0, - overduePurchaseOrders: 0, - onOrderUnits: 0, - onOrderCost: 0, - onOrderRetail: 0, - vendorOrders: [] - }); + res.status(500).json({ error: 'Failed to fetch purchase metrics' }); } }); @@ -233,7 +175,8 @@ router.get('/replenishment/metrics', async (req, res) => { ROUND(COALESCE(SUM(pm.replenishment_cost), 0)::numeric, 3) as total_cost, ROUND(COALESCE(SUM(pm.replenishment_retail), 0)::numeric, 3) as total_retail FROM product_metrics pm - WHERE pm.is_replenishable = true + WHERE pm.is_visible = true + AND pm.is_replenishable = true AND (pm.status IN ('Critical', 'Reorder') OR pm.current_stock < 0) AND pm.replenishment_units > 0 @@ -241,7 +184,7 @@ router.get('/replenishment/metrics', async (req, res) => { // Get top variants to replenish const { rows: variants } = await executeQuery(` - SELECT + SELECT pm.pid, pm.title, pm.current_stock::integer as current_stock, @@ -251,7 +194,8 @@ router.get('/replenishment/metrics', async (req, res) => { pm.status, pm.planning_period_days::text as planning_period FROM product_metrics pm - WHERE pm.is_replenishable = true + WHERE pm.is_visible = true + AND pm.is_replenishable = true AND (pm.status IN ('Critical', 'Reorder') OR pm.current_stock < 0) AND pm.replenishment_units > 0 @@ -264,25 +208,6 @@ router.get('/replenishment/metrics', async (req, res) => { LIMIT 5 `); - // If no data, provide dummy data - if (!metrics || variants.length === 0) { - console.log('No replenishment metrics found in new schema, returning dummy data'); - - return res.json({ - productsToReplenish: 15, - unitsToReplenish: 1500, - replenishmentCost: 15000.00, - replenishmentRetail: 30000.00, - topVariants: [ - { id: 1, title: "Test Product 1", currentStock: 5, replenishQty: 20, replenishCost: 500, replenishRetail: 1000, status: "Critical", planningPeriod: "30" }, - { id: 2, title: "Test Product 2", currentStock: 10, replenishQty: 15, replenishCost: 450, replenishRetail: 900, status: "Critical", planningPeriod: "30" }, - { id: 3, title: "Test Product 3", currentStock: 15, replenishQty: 10, replenishCost: 300, replenishRetail: 600, status: "Reorder", planningPeriod: "30" }, - { id: 4, title: "Test Product 4", currentStock: 20, replenishQty: 20, replenishCost: 200, replenishRetail: 400, status: "Reorder", planningPeriod: "30" }, - { id: 5, title: "Test Product 5", currentStock: 25, replenishQty: 10, replenishCost: 150, replenishRetail: 300, status: "Reorder", planningPeriod: "30" } - ] - }); - } - // Format response const response = { productsToReplenish: parseInt(metrics.products_to_replenish) || 0, @@ -304,21 +229,7 @@ router.get('/replenishment/metrics', async (req, res) => { res.json(response); } catch (err) { console.error('Error fetching replenishment metrics:', err); - - // Return dummy data on error - res.json({ - productsToReplenish: 15, - unitsToReplenish: 1500, - replenishmentCost: 15000.00, - replenishmentRetail: 30000.00, - topVariants: [ - { id: 1, title: "Test Product 1", currentStock: 5, replenishQty: 20, replenishCost: 500, replenishRetail: 1000, status: "Critical", planningPeriod: "30" }, - { id: 2, title: "Test Product 2", currentStock: 10, replenishQty: 15, replenishCost: 450, replenishRetail: 900, status: "Critical", planningPeriod: "30" }, - { id: 3, title: "Test Product 3", currentStock: 15, replenishQty: 10, replenishCost: 300, replenishRetail: 600, status: "Reorder", planningPeriod: "30" }, - { id: 4, title: "Test Product 4", currentStock: 20, replenishQty: 20, replenishCost: 200, replenishRetail: 400, status: "Reorder", planningPeriod: "30" }, - { id: 5, title: "Test Product 5", currentStock: 25, replenishQty: 10, replenishCost: 150, replenishRetail: 300, status: "Reorder", planningPeriod: "30" } - ] - }); + res.status(500).json({ error: 'Failed to fetch replenishment metrics' }); } }); @@ -488,11 +399,9 @@ router.get('/overstock/metrics', async (req, res) => { try { // Check if we have any products with Overstock status const { rows: [countCheck] } = await executeQuery(` - SELECT COUNT(*) as overstock_count FROM product_metrics WHERE status = 'Overstock' + SELECT COUNT(*) as overstock_count FROM product_metrics WHERE status = 'Overstock' AND is_visible = true `); - console.log('Overstock count:', countCheck.overstock_count); - // If no overstock products, return empty metrics if (parseInt(countCheck.overstock_count) === 0) { return res.json({ @@ -511,8 +420,9 @@ router.get('/overstock/metrics', async (req, res) => { SUM(overstocked_units)::integer as total_excess_units, ROUND(SUM(overstocked_cost)::numeric, 3) as total_excess_cost, ROUND(SUM(overstocked_retail)::numeric, 3) as total_excess_retail - FROM product_metrics + FROM product_metrics WHERE status = 'Overstock' + AND is_visible = true `); // Get category breakdowns separately @@ -527,14 +437,12 @@ router.get('/overstock/metrics', async (req, res) => { JOIN product_categories pc ON c.cat_id = pc.cat_id JOIN product_metrics pm ON pc.pid = pm.pid WHERE pm.status = 'Overstock' + AND pm.is_visible = true GROUP BY c.name ORDER BY total_excess_cost DESC LIMIT 8 `); - console.log('Summary metrics:', summaryMetrics); - console.log('Category data count:', categoryData.length); - // Format response with explicit type conversion const response = { overstockedProducts: parseInt(summaryMetrics.total_overstocked) || 0, @@ -553,20 +461,7 @@ router.get('/overstock/metrics', async (req, res) => { res.json(response); } catch (err) { console.error('Error fetching overstock metrics:', err); - - // Return dummy data on error - res.json({ - overstockedProducts: 10, - total_excess_units: 500, - total_excess_cost: 5000, - total_excess_retail: 10000, - category_data: [ - { category: "Electronics", products: 3, units: 150, cost: 1500, retail: 3000 }, - { category: "Clothing", products: 4, units: 200, cost: 2000, retail: 4000 }, - { category: "Home Goods", products: 2, units: 100, cost: 1000, retail: 2000 }, - { category: "Office Supplies", products: 1, units: 50, cost: 500, retail: 1000 } - ] - }); + res.status(500).json({ error: 'Failed to fetch overstock metrics' }); } }); @@ -587,7 +482,7 @@ router.get('/overstock/products', async (req, res) => { pm.current_price as price, pm.sales_velocity_daily as daily_sales_avg, pm.stock_cover_in_days as days_of_inventory, - pm.overstocked_units, + pm.overstocked_units as overstocked_amt, pm.overstocked_cost as excess_cost, pm.overstocked_retail as excess_retail, STRING_AGG(c.name, ', ') as categories @@ -595,6 +490,7 @@ router.get('/overstock/products', async (req, res) => { LEFT JOIN product_categories pc ON pm.pid = pc.pid LEFT JOIN categories c ON pc.cat_id = c.cat_id WHERE pm.status = 'Overstock' + AND pm.is_visible = true GROUP BY pm.pid, pm.sku, pm.title, pm.brand, pm.vendor, pm.current_stock, pm.current_cost_price, pm.current_price, pm.sales_velocity_daily, pm.stock_cover_in_days, pm.overstocked_units, pm.overstocked_cost, pm.overstocked_retail ORDER BY excess_cost DESC @@ -608,157 +504,70 @@ router.get('/overstock/products', async (req, res) => { }); // GET /dashboard/best-sellers -// Returns best-selling products, vendors, and categories +// Returns best-selling products, brands, and categories (from product_metrics) router.get('/best-sellers', async (req, res) => { try { - // 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 best selling products + // Best selling products const { rows: products } = await executeQuery(` - SELECT - p.pid, - p.SKU as sku, - p.title, - SUM(o.quantity) as units_sold, - ROUND(SUM(o.price * o.quantity)::numeric, 3) as revenue, - ROUND(SUM(o.price * o.quantity - p.cost_price * o.quantity)::numeric, 3) as profit - FROM products p - JOIN orders o ON p.pid = o.pid - WHERE o.date >= CURRENT_DATE - INTERVAL '30 days' - AND o.canceled = false - GROUP BY p.pid, p.SKU, p.title - ORDER BY units_sold DESC + SELECT + pm.pid, + pm.sku, + pm.title, + pm.sales_30d::integer as units_sold, + ROUND(pm.revenue_30d::numeric, 2) as revenue, + ROUND(pm.profit_30d::numeric, 2) as profit + FROM product_metrics pm + WHERE pm.is_visible = true + AND pm.sales_30d > 0 + ORDER BY pm.sales_30d DESC LIMIT 10 `); - // Get best selling brands + // Best selling brands const { rows: brands } = await executeQuery(` - SELECT - p.brand, - SUM(o.quantity) as units_sold, - ROUND(SUM(o.price * o.quantity)::numeric, 3) as revenue, - ROUND(SUM(o.price * o.quantity - p.cost_price * o.quantity)::numeric, 3) as profit, + SELECT + pm.brand, + SUM(pm.sales_30d)::integer as units_sold, + ROUND(SUM(pm.revenue_30d)::numeric, 2) as revenue, + ROUND(SUM(pm.profit_30d)::numeric, 2) as profit, ROUND( - ((SUM(CASE - WHEN o.date >= CURRENT_DATE - INTERVAL '30 days' - THEN o.price * o.quantity - ELSE 0 - END) / - NULLIF(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), 0)) - 1) * 100, - 1 + CASE WHEN SUM(pm.revenue_30d) > 0 AND COUNT(CASE WHEN pm.sales_growth_30d_vs_prev IS NOT NULL THEN 1 END) > 0 + THEN SUM(pm.revenue_30d * COALESCE(pm.sales_growth_30d_vs_prev, 0)) / NULLIF(SUM(CASE WHEN pm.sales_growth_30d_vs_prev IS NOT NULL THEN pm.revenue_30d ELSE 0 END), 0) + ELSE NULL + END::numeric, 1 ) as growth_rate - FROM products p - JOIN orders o ON p.pid = o.pid - WHERE o.date >= CURRENT_DATE - INTERVAL '60 days' - AND o.canceled = false - GROUP BY p.brand + FROM product_metrics pm + WHERE pm.is_visible = true + AND pm.sales_30d > 0 + GROUP BY pm.brand ORDER BY units_sold DESC LIMIT 10 `); - // Get best selling categories with full path + // Best selling categories with full path from materialized view const { rows: categories } = await executeQuery(` - ${categoryPathCTE} - SELECT + SELECT c.cat_id, c.name, - cp.path as categoryPath, - SUM(o.quantity) as units_sold, - ROUND(SUM(o.price * o.quantity)::numeric, 3) as revenue, - ROUND(SUM(o.price * o.quantity - p.cost_price * o.quantity)::numeric, 3) as profit, - ROUND( - ((SUM(CASE - WHEN o.date >= CURRENT_DATE - INTERVAL '30 days' - THEN o.price * o.quantity - ELSE 0 - END) / - NULLIF(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), 0)) - 1) * 100, - 1 - ) as growth_rate - FROM products p - JOIN orders o ON p.pid = o.pid - JOIN product_categories pc ON p.pid = pc.pid + ch.path as "categoryPath", + SUM(pm.sales_30d)::integer as units_sold, + ROUND(SUM(pm.revenue_30d)::numeric, 2) as revenue, + ROUND(SUM(pm.profit_30d)::numeric, 2) as profit + FROM product_metrics pm + JOIN product_categories pc ON pm.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' - AND o.canceled = false - GROUP BY c.cat_id, c.name, cp.path + JOIN category_hierarchy ch ON c.cat_id = ch.cat_id + WHERE pm.is_visible = true + AND pm.sales_30d > 0 + GROUP BY c.cat_id, c.name, ch.path ORDER BY units_sold DESC LIMIT 10 `); - // If there's no data, provide some test data - if (products.length === 0 && brands.length === 0 && categories.length === 0) { - console.log('No best sellers data found, returning dummy data'); - - return res.json({ - products: [ - {pid: 1, sku: 'TEST001', title: 'Test Product 1', units_sold: 100, revenue: '1000.00', profit: '400.00'}, - {pid: 2, sku: 'TEST002', title: 'Test Product 2', units_sold: 90, revenue: '900.00', profit: '360.00'}, - {pid: 3, sku: 'TEST003', title: 'Test Product 3', units_sold: 80, revenue: '800.00', profit: '320.00'}, - ], - brands: [ - {brand: 'Test Brand 1', units_sold: 200, revenue: '2000.00', profit: '800.00', growth_rate: '10.5'}, - {brand: 'Test Brand 2', units_sold: 150, revenue: '1500.00', profit: '600.00', growth_rate: '5.2'}, - ], - categories: [ - {cat_id: 1, name: 'Test Category 1', categoryPath: 'Test Category 1', units_sold: 150, revenue: '1500.00', profit: '600.00', growth_rate: '8.5'}, - {cat_id: 2, name: 'Test Category 2', categoryPath: 'Parent Category > Test Category 2', units_sold: 100, revenue: '1000.00', profit: '400.00', growth_rate: '4.2'}, - ] - }); - } - res.json({ products, brands, categories }); } catch (err) { console.error('Error fetching best sellers:', err); - res.status(500).json({ - error: 'Failed to fetch best sellers', - // Return dummy data on error - products: [ - {pid: 1, sku: 'TEST001', title: 'Test Product 1', units_sold: 100, revenue: '1000.00', profit: '400.00'}, - {pid: 2, sku: 'TEST002', title: 'Test Product 2', units_sold: 90, revenue: '900.00', profit: '360.00'}, - {pid: 3, sku: 'TEST003', title: 'Test Product 3', units_sold: 80, revenue: '800.00', profit: '320.00'}, - ], - brands: [ - {brand: 'Test Brand 1', units_sold: 200, revenue: '2000.00', profit: '800.00', growth_rate: '10.5'}, - {brand: 'Test Brand 2', units_sold: 150, revenue: '1500.00', profit: '600.00', growth_rate: '5.2'}, - ], - categories: [ - {cat_id: 1, name: 'Test Category 1', categoryPath: 'Test Category 1', units_sold: 150, revenue: '1500.00', profit: '600.00', growth_rate: '8.5'}, - {cat_id: 2, name: 'Test Category 2', categoryPath: 'Parent Category > Test Category 2', units_sold: 100, revenue: '1000.00', profit: '400.00', growth_rate: '4.2'}, - ] - }); + res.status(500).json({ error: 'Failed to fetch best sellers' }); } }); @@ -821,177 +630,55 @@ router.get('/sales/metrics', async (req, res) => { } }); -// GET /dashboard/low-stock/products -// Returns list of products with critical or low stock levels -router.get('/low-stock/products', async (req, res) => { - const limit = parseInt(req.query.limit) || 50; - try { - const { rows } = await executeQuery(` - SELECT - p.pid, - p.SKU, - p.title, - p.brand, - p.vendor, - p.stock_quantity, - p.cost_price, - p.price, - pm.daily_sales_avg, - pm.days_of_inventory, - pm.reorder_qty, - (pm.reorder_qty * p.cost_price) as reorder_cost, - STRING_AGG(c.name, ', ') as categories, - pm.lead_time_status - FROM products p - JOIN product_metrics pm ON p.pid = pm.pid - LEFT JOIN product_categories pc ON p.pid = pc.pid - LEFT JOIN categories c ON pc.cat_id = c.cat_id - WHERE pm.stock_status IN ('Critical', 'Reorder') - AND p.replenishable = true - GROUP BY p.pid, pm.daily_sales_avg, pm.days_of_inventory, pm.reorder_qty, pm.lead_time_status - ORDER BY - CASE pm.stock_status - WHEN 'Critical' THEN 1 - WHEN 'Reorder' THEN 2 - END, - pm.days_of_inventory ASC - LIMIT $1 - `, [limit]); - res.json(rows); - } catch (err) { - console.error('Error fetching low stock products:', err); - res.status(500).json({ error: 'Failed to fetch low stock products' }); - } -}); - -// GET /dashboard/trending/products -// Returns list of trending products based on recent sales velocity -router.get('/trending/products', async (req, res) => { - const days = parseInt(req.query.days) || 30; - const limit = parseInt(req.query.limit) || 20; - try { - const { rows } = await executeQuery(` - WITH recent_sales AS ( - SELECT - o.pid, - COUNT(DISTINCT o.order_number) as recent_orders, - SUM(o.quantity) as recent_units, - SUM(o.price * o.quantity) as recent_revenue - FROM orders o - WHERE o.canceled = false - AND o.date >= CURRENT_DATE - INTERVAL '${days} days' - GROUP BY o.pid - ) - SELECT - p.pid, - p.SKU, - p.title, - p.brand, - p.vendor, - p.stock_quantity, - rs.recent_orders, - rs.recent_units, - rs.recent_revenue, - pm.daily_sales_avg, - pm.stock_status, - (rs.recent_units::float / ${days}) as daily_velocity, - ((rs.recent_units::float / ${days}) - pm.daily_sales_avg) / NULLIF(pm.daily_sales_avg, 0) * 100 as velocity_change, - STRING_AGG(c.name, ', ') as categories - FROM recent_sales rs - JOIN products p ON rs.pid = p.pid - JOIN product_metrics pm ON p.pid = pm.pid - LEFT JOIN product_categories pc ON p.pid = pc.pid - LEFT JOIN categories c ON pc.cat_id = c.cat_id - GROUP BY p.pid, p.SKU, p.title, p.brand, p.vendor, p.stock_quantity, rs.recent_orders, rs.recent_units, rs.recent_revenue, pm.daily_sales_avg, pm.stock_status - HAVING ((rs.recent_units::float / ${days}) - pm.daily_sales_avg) / NULLIF(pm.daily_sales_avg, 0) * 100 > 0 - ORDER BY velocity_change DESC - LIMIT $1 - `, [limit]); - res.json(rows); - } catch (err) { - console.error('Error fetching trending products:', err); - res.status(500).json({ error: 'Failed to fetch trending products' }); - } -}); - // GET /dashboard/vendor/performance // Returns detailed vendor performance metrics router.get('/vendor/performance', async (req, res) => { - console.log('Vendor performance API called'); try { - // Set cache control headers to prevent 304 - res.set({ - 'Cache-Control': 'no-cache, no-store, must-revalidate', - 'Pragma': 'no-cache', - 'Expires': '0' - }); - - // First check if the purchase_orders table has data - const { rows: tableCheck } = await executeQuery(` - SELECT COUNT(*) as count FROM purchase_orders - `); - - console.log('Purchase orders count:', tableCheck[0].count); - - // If no purchase orders, return dummy data - never return empty array - if (parseInt(tableCheck[0].count) === 0) { - console.log('No purchase orders found, returning dummy data'); - return res.json([ - { - vendor: "Example Vendor 1", - total_orders: 12, - avg_lead_time: 7.5, - on_time_delivery_rate: 92.5, - avg_fill_rate: 97.0, - active_orders: 3, - overdue_orders: 0 - }, - { - vendor: "Example Vendor 2", - total_orders: 8, - avg_lead_time: 10.2, - on_time_delivery_rate: 87.5, - avg_fill_rate: 95.5, - active_orders: 2, - overdue_orders: 1 - }, - { - vendor: "Example Vendor 3", - total_orders: 5, - avg_lead_time: 15.0, - on_time_delivery_rate: 80.0, - avg_fill_rate: 92.0, - active_orders: 1, - overdue_orders: 0 - } - ]); - } - - const query = ` - WITH vendor_orders AS ( - SELECT + const { rows } = await executeQuery(` + WITH stale AS ( + SELECT po_id, pid + FROM purchase_orders po + WHERE po.status IN ('created', 'ordered', 'preordered', 'electronically_sent', + 'electronically_ready_send', 'receiving_started') + AND po.expected_date IS NOT NULL + AND po.expected_date < CURRENT_DATE - INTERVAL '90 days' + AND EXISTS ( + SELECT 1 FROM purchase_orders newer + WHERE newer.pid = po.pid + AND newer.status NOT IN ('canceled', 'done') + AND COALESCE(newer.date_ordered, newer.date_created) + > COALESCE(po.date_ordered, po.date_created) + ) + ), + vendor_orders AS ( + SELECT po.vendor, COUNT(DISTINCT po.po_id)::integer as total_orders, - COALESCE(ROUND(AVG(CASE WHEN po.received_date IS NOT NULL + COALESCE(ROUND(AVG(CASE WHEN po.received_date IS NOT NULL THEN EXTRACT(EPOCH FROM (po.received_date - po.date))/86400 ELSE NULL END)::numeric, 2), 0) as avg_lead_time, - COALESCE(ROUND(SUM(CASE - WHEN po.status = 'done' AND po.received_date <= po.expected_date - THEN 1 - ELSE 0 + COALESCE(ROUND(SUM(CASE + WHEN po.status = 'done' AND po.received_date <= po.expected_date + THEN 1 + ELSE 0 END)::numeric * 100.0 / NULLIF(COUNT(*)::numeric, 0), 2), 0) as on_time_delivery_rate, - COALESCE(ROUND(AVG(CASE - WHEN po.status = 'done' - THEN po.received::numeric / NULLIF(po.ordered::numeric, 0) * 100 - ELSE NULL + COALESCE(ROUND(AVG(CASE + WHEN po.status = 'done' + THEN po.received::numeric / NULLIF(po.ordered::numeric, 0) * 100 + ELSE NULL END)::numeric, 2), 0) as avg_fill_rate, - COUNT(CASE WHEN po.status IN ('created', 'electronically_ready_send', 'ordered', 'preordered', 'electronically_sent', 'receiving_started') THEN 1 END)::integer as active_orders, - COUNT(CASE WHEN po.status IN ('created', 'electronically_ready_send', 'ordered', 'preordered', 'electronically_sent', 'receiving_started') AND po.expected_date < CURRENT_DATE THEN 1 END)::integer as overdue_orders + COUNT(CASE WHEN po.status IN ('created', 'electronically_ready_send', 'ordered', 'preordered', 'electronically_sent', 'receiving_started') + AND NOT EXISTS (SELECT 1 FROM stale s WHERE s.po_id = po.po_id AND s.pid = po.pid) + THEN 1 END)::integer as active_orders, + COUNT(CASE WHEN po.status IN ('created', 'electronically_ready_send', 'ordered', 'preordered', 'electronically_sent', 'receiving_started') + AND po.expected_date < CURRENT_DATE + AND NOT EXISTS (SELECT 1 FROM stale s WHERE s.po_id = po.po_id AND s.pid = po.pid) + THEN 1 END)::integer as overdue_orders FROM purchase_orders po WHERE po.date >= CURRENT_DATE - INTERVAL '180 days' GROUP BY po.vendor ) - SELECT + SELECT vo.vendor, vo.total_orders, vo.avg_lead_time, @@ -1002,48 +689,8 @@ router.get('/vendor/performance', async (req, res) => { FROM vendor_orders vo ORDER BY vo.on_time_delivery_rate DESC LIMIT 10 - `; - - console.log('Executing vendor performance query'); - const { rows } = await executeQuery(query); - - console.log(`Query returned ${rows.length} vendors`); - - // If no vendor data found, return dummy data - never return empty array - if (rows.length === 0) { - console.log('No vendor data found, returning dummy data'); - return res.json([ - { - vendor: "Example Vendor 1", - total_orders: 12, - avg_lead_time: 7.5, - on_time_delivery_rate: 92.5, - avg_fill_rate: 97.0, - active_orders: 3, - overdue_orders: 0 - }, - { - vendor: "Example Vendor 2", - total_orders: 8, - avg_lead_time: 10.2, - on_time_delivery_rate: 87.5, - avg_fill_rate: 95.5, - active_orders: 2, - overdue_orders: 1 - }, - { - vendor: "Example Vendor 3", - total_orders: 5, - avg_lead_time: 15.0, - on_time_delivery_rate: 80.0, - avg_fill_rate: 92.0, - active_orders: 1, - overdue_orders: 0 - } - ]); - } - - // Transform data to ensure numeric values are properly formatted + `); + const formattedData = rows.map(row => ({ vendor: row.vendor, total_orders: Number(row.total_orders) || 0, @@ -1053,196 +700,11 @@ router.get('/vendor/performance', async (req, res) => { active_orders: Number(row.active_orders) || 0, overdue_orders: Number(row.overdue_orders) || 0 })); - - console.log('Returning vendor data:', formattedData); + res.json(formattedData); } catch (err) { console.error('Error fetching vendor performance:', err); - console.error('Error details:', err.message); - - // Return dummy data on error - res.json([ - { - vendor: "Example Vendor 1", - total_orders: 12, - avg_lead_time: 7.5, - on_time_delivery_rate: 92.5, - avg_fill_rate: 97.0, - active_orders: 3, - overdue_orders: 0 - }, - { - vendor: "Example Vendor 2", - total_orders: 8, - avg_lead_time: 10.2, - on_time_delivery_rate: 87.5, - avg_fill_rate: 95.5, - active_orders: 2, - overdue_orders: 1 - }, - { - vendor: "Example Vendor 3", - total_orders: 5, - avg_lead_time: 15.0, - on_time_delivery_rate: 80.0, - avg_fill_rate: 92.0, - active_orders: 1, - overdue_orders: 0 - } - ]); - } -}); - -// GET /dashboard/key-metrics -// Returns key business metrics and KPIs -router.get('/key-metrics', async (req, res) => { - const days = Math.max(1, Math.min(365, parseInt(req.query.days) || 30)); - try { - const { rows } = await executeQuery(` - WITH inventory_summary AS ( - SELECT - COUNT(*) as total_products, - SUM(p.stock_quantity * p.cost_price) as total_inventory_value, - AVG(pm.turnover_rate) as avg_turnover_rate, - COUNT(CASE WHEN pm.stock_status = 'Critical' THEN 1 END) as critical_stock_count, - COUNT(CASE WHEN pm.stock_status = 'Overstocked' THEN 1 END) as overstock_count - FROM products p - JOIN product_metrics pm ON p.pid = pm.pid - ), - sales_summary AS ( - SELECT - COUNT(DISTINCT order_number) as total_orders, - SUM(quantity) as total_units_sold, - SUM(price * quantity) as total_revenue, - AVG(price * quantity) as avg_order_value - FROM orders - WHERE canceled = false - AND date >= CURRENT_DATE - INTERVAL '${days} days' - ), - purchase_summary AS ( - SELECT - COUNT(DISTINCT po_id) as total_pos, - SUM(ordered * cost_price) as total_po_value, - COUNT(CASE WHEN status IN ('created', 'electronically_ready_send', 'ordered', 'preordered', 'electronically_sent', 'receiving_started') THEN 1 END) as open_pos - FROM purchase_orders - WHERE order_date >= CURRENT_DATE - INTERVAL '${days} days' - ) - SELECT - i.*, - s.*, - p.* - FROM inventory_summary i - CROSS JOIN sales_summary s - CROSS JOIN purchase_summary p - `); - res.json(rows[0] || {}); - } catch (err) { - console.error('Error fetching key metrics:', err); - res.status(500).json({ error: 'Failed to fetch key metrics' }); - } -}); - -// GET /dashboard/inventory-health -// Returns overall inventory health metrics -router.get('/inventory-health', async (req, res) => { - try { - const { rows } = await executeQuery(` - WITH stock_distribution AS ( - SELECT - COUNT(*) as total_products, - SUM(CASE WHEN pm.stock_status = 'Healthy' THEN 1 ELSE 0 END) * 100.0 / COUNT(*) as healthy_stock_percent, - SUM(CASE WHEN pm.stock_status = 'Critical' THEN 1 ELSE 0 END) * 100.0 / COUNT(*) as critical_stock_percent, - SUM(CASE WHEN pm.stock_status = 'Reorder' THEN 1 ELSE 0 END) * 100.0 / COUNT(*) as reorder_stock_percent, - SUM(CASE WHEN pm.stock_status = 'Overstocked' THEN 1 ELSE 0 END) * 100.0 / COUNT(*) as overstock_percent, - AVG(pm.turnover_rate) as avg_turnover_rate, - AVG(pm.days_of_inventory) as avg_days_inventory - FROM products p - JOIN product_metrics pm ON p.pid = pm.pid - WHERE p.replenishable = true - ), - value_distribution AS ( - SELECT - SUM(p.stock_quantity * p.cost_price) as total_inventory_value, - SUM(CASE - WHEN pm.stock_status = 'Healthy' - THEN p.stock_quantity * p.cost_price - ELSE 0 - END) * 100.0 / NULLIF(SUM(p.stock_quantity * p.cost_price), 0) as healthy_value_percent, - SUM(CASE - WHEN pm.stock_status = 'Critical' - THEN p.stock_quantity * p.cost_price - ELSE 0 - END) * 100.0 / NULLIF(SUM(p.stock_quantity * p.cost_price), 0) as critical_value_percent, - SUM(CASE - WHEN pm.stock_status = 'Overstocked' - THEN p.stock_quantity * p.cost_price - ELSE 0 - END) * 100.0 / NULLIF(SUM(p.stock_quantity * p.cost_price), 0) as overstock_value_percent - FROM products p - JOIN product_metrics pm ON p.pid = pm.pid - ), - category_health AS ( - SELECT - c.name as category_name, - COUNT(*) as category_products, - SUM(CASE WHEN pm.stock_status = 'Healthy' THEN 1 ELSE 0 END) * 100.0 / COUNT(*) as category_healthy_percent, - AVG(pm.turnover_rate) as category_turnover_rate - FROM categories c - JOIN product_categories pc ON c.cat_id = pc.cat_id - JOIN products p ON pc.pid = p.pid - JOIN product_metrics pm ON p.pid = pm.pid - WHERE p.replenishable = true - GROUP BY c.cat_id, c.name - ) - SELECT - sd.*, - vd.*, - json_agg( - json_build_object( - 'category', ch.category_name, - 'products', ch.category_products, - 'healthy_percent', ch.category_healthy_percent, - 'turnover_rate', ch.category_turnover_rate - ) - ) as category_health - FROM stock_distribution sd - CROSS JOIN value_distribution vd - CROSS JOIN category_health ch - GROUP BY - sd.total_products, - sd.healthy_stock_percent, - sd.critical_stock_percent, - sd.reorder_stock_percent, - sd.overstock_percent, - sd.avg_turnover_rate, - sd.avg_days_inventory, - vd.total_inventory_value, - vd.healthy_value_percent, - vd.critical_value_percent, - vd.overstock_value_percent - `); - - if (rows.length === 0) { - return res.json({ - total_products: 0, - healthy_stock_percent: 0, - critical_stock_percent: 0, - reorder_stock_percent: 0, - overstock_percent: 0, - avg_turnover_rate: 0, - avg_days_inventory: 0, - total_inventory_value: 0, - healthy_value_percent: 0, - critical_value_percent: 0, - overstock_value_percent: 0, - category_health: [] - }); - } - - res.json(rows[0]); - } catch (err) { - console.error('Error fetching inventory health:', err); - res.status(500).json({ error: 'Failed to fetch inventory health' }); + res.status(500).json({ error: 'Failed to fetch vendor performance' }); } }); @@ -1261,14 +723,15 @@ router.get('/replenish/products', async (req, res) => { pm.replenishment_units AS reorder_qty, pm.date_last_received AS last_purchase_date FROM product_metrics pm - WHERE pm.is_replenishable = true + WHERE pm.is_visible = true + AND pm.is_replenishable = true AND (pm.status IN ('Critical', 'Reorder') OR pm.current_stock < 0) AND pm.replenishment_units > 0 - ORDER BY - CASE pm.status - WHEN 'Critical' THEN 1 - WHEN 'Reorder' THEN 2 + ORDER BY + CASE pm.status + WHEN 'Critical' THEN 1 + WHEN 'Reorder' THEN 2 END, pm.replenishment_cost DESC LIMIT $1 @@ -1280,60 +743,4 @@ router.get('/replenish/products', async (req, res) => { } }); -// GET /dashboard/sales-overview -// Returns sales overview data for the chart in Overview.tsx -router.get('/sales-overview', async (req, res) => { - try { - const { rows } = await executeQuery(` - SELECT - DATE(date) as date, - ROUND(SUM(price * quantity)::numeric, 3) as total - FROM orders - WHERE canceled = false - AND date >= CURRENT_DATE - INTERVAL '30 days' - GROUP BY DATE(date) - ORDER BY date ASC - `); - - // If no data, generate dummy data - if (rows.length === 0) { - console.log('No sales overview data found, returning dummy data'); - const dummyData = []; - const today = new Date(); - - // Generate 30 days of dummy data - for (let i = 0; i < 30; i++) { - const date = new Date(today); - date.setDate(today.getDate() - (29 - i)); - dummyData.push({ - date: date.toISOString().split('T')[0], - total: Math.floor(1000 + Math.random() * 2000) - }); - } - - return res.json(dummyData); - } - - res.json(rows); - } catch (err) { - console.error('Error fetching sales overview:', err); - - // Generate dummy data on error - const dummyData = []; - const today = new Date(); - - // Generate 30 days of dummy data - for (let i = 0; i < 30; i++) { - const date = new Date(today); - date.setDate(today.getDate() - (29 - i)); - dummyData.push({ - date: date.toISOString().split('T')[0], - total: Math.floor(1000 + Math.random() * 2000) - }); - } - - res.json(dummyData); - } -}); - module.exports = router; \ No newline at end of file diff --git a/inventory-server/src/routes/purchase-orders.js b/inventory-server/src/routes/purchase-orders.js index 8f6f838..d51b30c 100644 --- a/inventory-server/src/routes/purchase-orders.js +++ b/inventory-server/src/routes/purchase-orders.js @@ -1190,39 +1190,68 @@ router.get('/pipeline', async (req, res) => { try { const pool = req.app.locals.pool; - // Expected arrivals by week (ordered + electronically_sent with expected_date) + // Stale PO filter (reused across queries) + const staleFilter = ` + WITH stale AS ( + SELECT po_id, pid + FROM purchase_orders po + WHERE po.status IN ('created', 'ordered', 'preordered', 'electronically_sent', + 'electronically_ready_send', 'receiving_started') + AND po.expected_date IS NOT NULL + AND po.expected_date < CURRENT_DATE - INTERVAL '90 days' + AND EXISTS ( + SELECT 1 FROM purchase_orders newer + WHERE newer.pid = po.pid + AND newer.status NOT IN ('canceled', 'done') + AND COALESCE(newer.date_ordered, newer.date_created) + > COALESCE(po.date_ordered, po.date_created) + ) + )`; + + // Expected arrivals by week (excludes stale POs) const { rows: arrivals } = await pool.query(` + ${staleFilter} SELECT - DATE_TRUNC('week', expected_date)::date AS week, - COUNT(DISTINCT po_id) AS po_count, - ROUND(SUM(po_cost_price * ordered)::numeric, 0) AS expected_value, - COUNT(DISTINCT vendor) AS vendor_count - FROM purchase_orders - WHERE status IN ('ordered', 'electronically_sent') - AND expected_date IS NOT NULL + DATE_TRUNC('week', po.expected_date)::date AS week, + COUNT(DISTINCT po.po_id) AS po_count, + ROUND(SUM(po.po_cost_price * po.ordered)::numeric, 0) AS expected_value, + COUNT(DISTINCT po.vendor) AS vendor_count + FROM purchase_orders po + WHERE po.status IN ('ordered', 'electronically_sent') + AND po.expected_date IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM stale s WHERE s.po_id = po.po_id AND s.pid = po.pid) GROUP BY 1 ORDER BY 1 `); - // Overdue POs (expected_date in the past) + // Overdue POs (excludes stale) const { rows: [overdue] } = await pool.query(` + ${staleFilter} SELECT - COUNT(DISTINCT po_id) AS po_count, - ROUND(COALESCE(SUM(po_cost_price * ordered), 0)::numeric, 0) AS total_value - FROM purchase_orders - WHERE status IN ('ordered', 'electronically_sent') - AND expected_date IS NOT NULL - AND expected_date < CURRENT_DATE + COUNT(DISTINCT po.po_id) AS po_count, + ROUND(COALESCE(SUM(po.po_cost_price * po.ordered), 0)::numeric, 0) AS total_value + FROM purchase_orders po + WHERE po.status IN ('ordered', 'electronically_sent') + AND po.expected_date IS NOT NULL + AND po.expected_date < CURRENT_DATE + AND NOT EXISTS (SELECT 1 FROM stale s WHERE s.po_id = po.po_id AND s.pid = po.pid) `); - // Summary: all open POs + // Summary: on-order value from product_metrics (FIFO-accurate), PO counts from purchase_orders with staleness filter const { rows: [summary] } = await pool.query(` + ${staleFilter} SELECT - COUNT(DISTINCT po_id) AS total_open_pos, - ROUND(COALESCE(SUM(po_cost_price * ordered), 0)::numeric, 0) AS total_on_order_value, - COUNT(DISTINCT vendor) AS vendor_count - FROM purchase_orders - WHERE status IN ('ordered', 'electronically_sent') + COUNT(DISTINCT po.po_id) AS total_open_pos, + COUNT(DISTINCT po.vendor) AS vendor_count + FROM purchase_orders po + WHERE po.status IN ('ordered', 'electronically_sent') + AND NOT EXISTS (SELECT 1 FROM stale s WHERE s.po_id = po.po_id AND s.pid = po.pid) + `); + + const { rows: [onOrderTotal] } = await pool.query(` + SELECT ROUND(COALESCE(SUM(on_order_cost), 0)::numeric, 0) AS total_on_order_value + FROM product_metrics + WHERE is_visible = true `); res.json({ @@ -1238,7 +1267,7 @@ router.get('/pipeline', async (req, res) => { }, summary: { totalOpenPOs: Number(summary.total_open_pos) || 0, - totalOnOrderValue: Number(summary.total_on_order_value) || 0, + totalOnOrderValue: Number(onOrderTotal.total_on_order_value) || 0, vendorCount: Number(summary.vendor_count) || 0, }, }); diff --git a/inventory/src/components/analytics/CapitalEfficiency.tsx b/inventory/src/components/analytics/CapitalEfficiency.tsx index b11fc6d..c507aad 100644 --- a/inventory/src/components/analytics/CapitalEfficiency.tsx +++ b/inventory/src/components/analytics/CapitalEfficiency.tsx @@ -19,8 +19,8 @@ import config from '../../config'; import { METRIC_COLORS } from '@/lib/dashboard/designTokens'; import { formatCurrency } from '@/utils/formatCurrency'; -interface VendorData { - vendor: string; +interface BrandData { + brand: string; productCount: number; stockCost: number; profit30d: number; @@ -29,7 +29,7 @@ interface VendorData { } interface EfficiencyData { - vendors: VendorData[]; + brands: BrandData[]; } function getGmroiColor(gmroi: number): string { @@ -79,8 +79,8 @@ export function CapitalEfficiency() { // Top or bottom 15 by GMROI for bar chart const sortedGmroi = gmroiView === 'top' - ? [...data.vendors].sort((a, b) => b.gmroi - a.gmroi).slice(0, 15) - : [...data.vendors].sort((a, b) => a.gmroi - b.gmroi).slice(0, 15); + ? [...data.brands].sort((a, b) => b.gmroi - a.gmroi).slice(0, 15) + : [...data.brands].sort((a, b) => a.gmroi - b.gmroi).slice(0, 15); return (
@@ -88,9 +88,9 @@ export function CapitalEfficiency() {
- GMROI by Vendor + GMROI by Brand

- Annualized gross margin return on investment (top 30 vendors by stock value) + Annualized gross margin return on investment (top 30 brands by stock value)

@@ -117,17 +117,17 @@ export function CapitalEfficiency() { { if (!active || !payload?.length) return null; - const d = payload[0].payload as VendorData; + const d = payload[0].payload as BrandData; return (
-

{d.vendor}

+

{d.brand}

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

Stock Investment: {formatCurrency(d.stockCost)}

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

@@ -150,7 +150,7 @@ export function CapitalEfficiency() { - Investment vs Profit by Vendor + Investment vs Profit by Brand

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

@@ -179,10 +179,10 @@ export function CapitalEfficiency() { { if (!active || !payload?.length) return null; - const d = payload[0].payload as VendorData; + const d = payload[0].payload as BrandData; return (
-

{d.vendor}

+

{d.brand}

Stock Investment: {formatCurrency(d.stockCost)}

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

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

@@ -191,7 +191,7 @@ export function CapitalEfficiency() { ); }} /> - + diff --git a/inventory/src/components/analytics/GrowthMomentum.tsx b/inventory/src/components/analytics/GrowthMomentum.tsx index 4dfcfda..71751da 100644 --- a/inventory/src/components/analytics/GrowthMomentum.tsx +++ b/inventory/src/components/analytics/GrowthMomentum.tsx @@ -12,7 +12,8 @@ import { } from 'recharts'; import config from '../../config'; import { METRIC_COLORS } from '@/lib/dashboard/designTokens'; -import { TrendingUp, TrendingDown } from 'lucide-react'; +import { TrendingUp, TrendingDown, Plus, Archive } from 'lucide-react'; +import { formatCurrency } from '@/utils/formatCurrency'; interface GrowthRow { abcClass: string; @@ -23,16 +24,24 @@ interface GrowthRow { } interface GrowthSummary { - totalWithYoy: number; + comparableCount: number; growingCount: number; decliningCount: number; - avgGrowth: number; + weightedAvgGrowth: number; medianGrowth: number; } +interface CatalogTurnover { + newProducts: number; + newProductRevenue: number; + discontinued: number; + discontinuedStockValue: number; +} + interface GrowthData { byClass: GrowthRow[]; summary: GrowthSummary; + turnover: CatalogTurnover; } const GROWTH_COLORS: Record = { @@ -78,9 +87,9 @@ export function GrowthMomentum() { ); } - const { summary } = data; - const growthPct = summary.totalWithYoy > 0 - ? ((summary.growingCount / summary.totalWithYoy) * 100).toFixed(0) + const { summary, turnover } = data; + const growingPct = summary.comparableCount > 0 + ? ((summary.growingCount / summary.comparableCount) * 100).toFixed(0) : '0'; // Pivot: for each ABC class, show product counts by growth bucket @@ -97,6 +106,7 @@ export function GrowthMomentum() { return (
+ {/* Row 1: Comparable growth metrics */}
@@ -104,9 +114,9 @@ export function GrowthMomentum() {
-

Growing

-

{growthPct}%

-

{summary.growingCount.toLocaleString()} products

+

Comparable Growing

+

{growingPct}%

+

{summary.growingCount.toLocaleString()} of {summary.comparableCount.toLocaleString()} products

@@ -116,18 +126,19 @@ export function GrowthMomentum() {
-

Declining

+

Comparable Declining

{summary.decliningCount.toLocaleString()}

-

products

+

products with lower YoY sales

-

Avg YoY Growth

-

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

Weighted Avg Growth

+

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

+

revenue-weighted

@@ -136,16 +147,49 @@ export function GrowthMomentum() {

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

-

{summary.totalWithYoy.toLocaleString()} products tracked

+

typical product growth

+ {/* Row 2: Catalog turnover */} +
+ + +
+ +
+
+

New Products (<1yr)

+

{turnover.newProducts.toLocaleString()}

+

{formatCurrency(turnover.newProductRevenue)} revenue (30d)

+
+
+
+ + +
+ +
+
+

Discontinued

+

{turnover.discontinued.toLocaleString()}

+

+ {turnover.discontinuedStockValue > 0 + ? `${formatCurrency(turnover.discontinuedStockValue)} still in stock` + : 'no remaining stock'} +

+
+
+
+
+ + {/* Chart: comparable products only */} - Growth Distribution by ABC Class + Comparable Growth by ABC Class

- Year-over-year sales growth segmented by product importance + Products selling in both this and last year's period — excludes new launches and discontinued

diff --git a/inventory/src/components/analytics/StockoutRisk.tsx b/inventory/src/components/analytics/StockoutRisk.tsx index 764cb99..ed7392e 100644 --- a/inventory/src/components/analytics/StockoutRisk.tsx +++ b/inventory/src/components/analytics/StockoutRisk.tsx @@ -18,7 +18,7 @@ import { formatCurrency } from '@/utils/formatCurrency'; interface RiskProduct { title: string; sku: string; - vendor: string; + brand: string; leadTimeDays: number; sellsOutInDays: number; currentStock: number; diff --git a/inventory/src/components/overview/BestSellers.tsx b/inventory/src/components/overview/BestSellers.tsx index bd387ea..c45b4e9 100644 --- a/inventory/src/components/overview/BestSellers.tsx +++ b/inventory/src/components/overview/BestSellers.tsx @@ -4,7 +4,7 @@ import { ScrollArea } from "@/components/ui/scroll-area" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import config from "@/config" -import { formatCurrency } from "@/lib/utils" +import { formatCurrency } from "@/utils/formatCurrency" interface Product { pid: number; @@ -22,7 +22,6 @@ interface Category { units_sold: number; revenue: string; profit: string; - growth_rate: string; } interface BestSellerBrand { @@ -39,14 +38,22 @@ interface BestSellersData { categories: Category[] } +function TableSkeleton() { + return ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ ))} +
+ ); +} + export function BestSellers() { - const { data } = useQuery({ + const { data, isError, isLoading } = useQuery({ queryKey: ["best-sellers"], queryFn: async () => { const response = await fetch(`${config.apiUrl}/dashboard/best-sellers`) - if (!response.ok) { - throw new Error("Failed to fetch best sellers") - } + if (!response.ok) throw new Error("Failed to fetch best sellers"); return response.json() }, }) @@ -65,111 +72,121 @@ export function BestSellers() {
- - - - - - Product - Units Sold - Revenue - Profit - - - - {data?.products.map((product) => ( - - - - {product.title} - -
{product.sku}
-
- {product.units_sold} - {formatCurrency(Number(product.revenue))} - {formatCurrency(Number(product.profit))} -
- ))} -
-
-
-
+ {isError ? ( +

Failed to load best sellers

+ ) : isLoading ? ( + + ) : ( + <> + + + + + + Product + Units Sold + Revenue + Profit + + + + {data?.products.map((product) => ( + + + + {product.title} + +
{product.sku}
+
+ {product.units_sold} + {formatCurrency(Number(product.revenue))} + {formatCurrency(Number(product.profit))} +
+ ))} +
+
+
+
- - - - - - Brand - Sales - Revenue - Profit - Growth - - - - {data?.brands.map((brand) => ( - - -

{brand.brand}

-
- - {brand.units_sold.toLocaleString()} - - - {formatCurrency(Number(brand.revenue))} - - - {formatCurrency(Number(brand.profit))} - - - {Number(brand.growth_rate) > 0 ? '+' : ''}{Number(brand.growth_rate).toFixed(1)}% - -
- ))} -
-
-
-
+ + + + + + Brand + Sales + Revenue + Profit + Growth + + + + {data?.brands.map((brand) => ( + + +

{brand.brand}

+
+ + {brand.units_sold.toLocaleString()} + + + {formatCurrency(Number(brand.revenue))} + + + {formatCurrency(Number(brand.profit))} + + + {brand.growth_rate != null ? ( + <>{Number(brand.growth_rate) > 0 ? '+' : ''}{Number(brand.growth_rate).toFixed(1)}% + ) : '-'} + +
+ ))} +
+
+
+
- - - - - - Category - Units Sold - Revenue - Profit - - - - {data?.categories.map((category) => ( - - -
{category.name}
- {category.categoryPath && ( -
- {category.categoryPath} -
- )} -
- {category.units_sold} - {formatCurrency(Number(category.revenue))} - {formatCurrency(Number(category.profit))} -
- ))} -
-
-
-
+ + + + + + Category + Units Sold + Revenue + Profit + + + + {data?.categories.map((category) => ( + + +
{category.name}
+ {category.categoryPath && ( +
+ {category.categoryPath} +
+ )} +
+ {category.units_sold} + {formatCurrency(Number(category.revenue))} + {formatCurrency(Number(category.profit))} +
+ ))} +
+
+
+
+ + )}
) -} \ No newline at end of file +} diff --git a/inventory/src/components/overview/ForecastMetrics.tsx b/inventory/src/components/overview/ForecastMetrics.tsx index afadb5a..8669792 100644 --- a/inventory/src/components/overview/ForecastMetrics.tsx +++ b/inventory/src/components/overview/ForecastMetrics.tsx @@ -3,7 +3,7 @@ import { CardHeader, CardTitle, CardContent } from "@/components/ui/card" import { AreaChart, Area, ResponsiveContainer, XAxis, YAxis, Tooltip } from "recharts" import { useState } from "react" import config from "@/config" -import { formatCurrency } from "@/lib/utils" +import { formatCurrency } from "@/utils/formatCurrency" import { TrendingUp, DollarSign } from "lucide-react" import { DateRange } from "react-day-picker" import { addDays, format } from "date-fns" diff --git a/inventory/src/components/overview/OverstockMetrics.tsx b/inventory/src/components/overview/OverstockMetrics.tsx index c026a73..ec2d21f 100644 --- a/inventory/src/components/overview/OverstockMetrics.tsx +++ b/inventory/src/components/overview/OverstockMetrics.tsx @@ -1,7 +1,7 @@ import { useQuery } from "@tanstack/react-query" import { CardHeader, CardTitle, CardContent } from "@/components/ui/card" import config from "@/config" -import { formatCurrency } from "@/lib/utils" +import { formatCurrency } from "@/utils/formatCurrency" import { Package, Layers, DollarSign, ShoppingCart } from "lucide-react" interface OverstockMetricsData { @@ -18,15 +18,17 @@ interface OverstockMetricsData { }[] } +function MetricSkeleton() { + return
; +} + export function OverstockMetrics() { - const { data } = useQuery({ + const { data, isError, isLoading } = useQuery({ queryKey: ["overstock-metrics"], queryFn: async () => { const response = await fetch(`${config.apiUrl}/dashboard/overstock/metrics`) - if (!response.ok) { - throw new Error("Failed to fetch overstock metrics") - } - return response.json() + if (!response.ok) throw new Error('Failed to fetch overstock metrics'); + return response.json(); }, }) @@ -36,37 +38,49 @@ export function OverstockMetrics() { Overstock -
-
-
- -

Overstocked Products

+ {isError ? ( +

Failed to load overstock metrics

+ ) : ( +
+
+
+ +

Overstocked Products

+
+ {isLoading || !data ? : ( +

{data.overstockedProducts.toLocaleString()}

+ )}
-

{data?.overstockedProducts.toLocaleString() || 0}

-
-
-
- -

Overstocked Units

+
+
+ +

Overstocked Units

+
+ {isLoading || !data ? : ( +

{data.total_excess_units.toLocaleString()}

+ )}
-

{data?.total_excess_units.toLocaleString() || 0}

-
-
-
- -

Overstocked Cost

+
+
+ +

Overstocked Cost

+
+ {isLoading || !data ? : ( +

{formatCurrency(data.total_excess_cost)}

+ )}
-

{formatCurrency(data?.total_excess_cost || 0)}

-
-
-
- -

Overstocked Retail

+
+
+ +

Overstocked Retail

+
+ {isLoading || !data ? : ( +

{formatCurrency(data.total_excess_retail)}

+ )}
-

{formatCurrency(data?.total_excess_retail || 0)}

-
+ )} ) -} \ No newline at end of file +} diff --git a/inventory/src/components/overview/Overview.tsx b/inventory/src/components/overview/Overview.tsx deleted file mode 100644 index cf5ea87..0000000 --- a/inventory/src/components/overview/Overview.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; -import { Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; -import config from '../../config'; - -interface SalesData { - date: string; - total: number; -} - -export function Overview() { - const { data, isLoading, error } = useQuery({ - queryKey: ['sales-overview'], - queryFn: async () => { - const response = await fetch(`${config.apiUrl}/dashboard/sales-overview`); - if (!response.ok) { - throw new Error('Failed to fetch sales overview'); - } - const rawData = await response.json(); - return rawData.map((item: SalesData) => ({ - ...item, - total: parseFloat(item.total.toString()), - date: new Date(item.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) - })); - }, - }); - - if (isLoading) { - return
Loading chart...
; - } - - if (error) { - return
Error loading sales overview
; - } - - return ( - - - - `$${value.toLocaleString()}`} - /> - [`$${value.toLocaleString()}`, 'Sales']} - labelFormatter={(label) => `Date: ${label}`} - /> - - - - ); -} \ No newline at end of file diff --git a/inventory/src/components/overview/PurchaseMetrics.tsx b/inventory/src/components/overview/PurchaseMetrics.tsx index e07e3c7..400abfd 100644 --- a/inventory/src/components/overview/PurchaseMetrics.tsx +++ b/inventory/src/components/overview/PurchaseMetrics.tsx @@ -2,16 +2,16 @@ import { useQuery } from "@tanstack/react-query" import { CardHeader, CardTitle, CardContent } from "@/components/ui/card" import { PieChart, Pie, ResponsiveContainer, Cell, Sector } from "recharts" import config from "@/config" -import { formatCurrency } from "@/lib/utils" -import { ClipboardList, AlertCircle, Layers, DollarSign, ShoppingCart } from "lucide-react" // Importing icons +import { formatCurrency } from "@/utils/formatCurrency" +import { ClipboardList, AlertCircle, Layers, DollarSign, ShoppingCart } from "lucide-react" import { useState } from "react" interface PurchaseMetricsData { - activePurchaseOrders: number // Orders that are not canceled, done, or fully received - overduePurchaseOrders: number // Orders past their expected delivery date - onOrderUnits: number // Total units across all active orders - onOrderCost: number // Total cost across all active orders - onOrderRetail: number // Total retail value across all active orders + activePurchaseOrders: number + overduePurchaseOrders: number + onOrderUnits: number + onOrderCost: number + onOrderRetail: number vendorOrders: { vendor: string orders: number @@ -34,12 +34,11 @@ const COLORS = [ const renderActiveShape = (props: any) => { const { cx, cy, innerRadius, outerRadius, startAngle, endAngle, fill, vendor, cost } = props; - - // Split vendor name into words and create lines of max 12 chars + const words = vendor.split(' '); const lines: string[] = []; let currentLine = ''; - + words.forEach((word: string) => { if ((currentLine + ' ' + word).length <= 12) { currentLine = currentLine ? `${currentLine} ${word}` : word; @@ -52,151 +51,136 @@ const renderActiveShape = (props: any) => { return ( - - + + {lines.map((line, i) => ( - + {line} ))} - + {formatCurrency(cost)} ); }; +function MetricSkeleton() { + return
; +} + export function PurchaseMetrics() { const [activeIndex, setActiveIndex] = useState(); - const { data, error, isLoading } = useQuery({ + const { data, isError, isLoading } = useQuery({ queryKey: ["purchase-metrics"], queryFn: async () => { const response = await fetch(`${config.apiUrl}/dashboard/purchase/metrics`) - if (!response.ok) { - const text = await response.text(); - console.error('API Error:', text); - throw new Error(`Failed to fetch purchase metrics: ${response.status} ${response.statusText}`); - } - const data = await response.json(); - return data; + if (!response.ok) throw new Error('Failed to fetch purchase metrics'); + return response.json(); }, }) - if (isLoading) return
Loading...
; - if (error) return
Error loading purchase metrics
; - return ( <> Purchases -
-
-
-
-
- -

Active Purchase Orders

+ {isError ? ( +

Failed to load purchase metrics

+ ) : ( +
+
+
+
+
+ +

Active Purchase Orders

+
+ {isLoading || !data ? : ( +

{data.activePurchaseOrders.toLocaleString()}

+ )} +
+
+
+ +

Overdue Purchase Orders

+
+ {isLoading || !data ? : ( +

{data.overduePurchaseOrders.toLocaleString()}

+ )} +
+
+
+ +

On Order Units

+
+ {isLoading || !data ? : ( +

{data.onOrderUnits.toLocaleString()}

+ )} +
+
+
+ +

On Order Cost

+
+ {isLoading || !data ? : ( +

{formatCurrency(data.onOrderCost)}

+ )} +
+
+
+ +

On Order Retail

+
+ {isLoading || !data ? : ( +

{formatCurrency(data.onOrderRetail)}

+ )}
-

{data?.activePurchaseOrders.toLocaleString() || 0}

-
-
- -

Overdue Purchase Orders

+
+
+
+
Purchase Orders By Vendor
+
+ {isLoading || !data ? ( +
+
+
+ ) : ( + + + setActiveIndex(index)} + onMouseLeave={() => setActiveIndex(undefined)} + > + {data.vendorOrders.map((entry, index) => ( + + ))} + + + + )}
-

{data?.overduePurchaseOrders.toLocaleString() || 0}

-
-
-
- -

On Order Units

-
-

{data?.onOrderUnits.toLocaleString() || 0}

-
-
-
- -

On Order Cost

-
-

{formatCurrency(data?.onOrderCost || 0)}

-
-
-
- -

On Order Retail

-
-

{formatCurrency(data?.onOrderRetail || 0)}

-
-
-
Purchase Orders By Vendor
-
- - - setActiveIndex(index)} - onMouseLeave={() => setActiveIndex(undefined)} - > - {data?.vendorOrders?.map((entry, index) => ( - - ))} - - - -
-
-
-
+ )} ) -} \ No newline at end of file +} diff --git a/inventory/src/components/overview/ReplenishmentMetrics.tsx b/inventory/src/components/overview/ReplenishmentMetrics.tsx index 3950376..7b31e6f 100644 --- a/inventory/src/components/overview/ReplenishmentMetrics.tsx +++ b/inventory/src/components/overview/ReplenishmentMetrics.tsx @@ -1,8 +1,8 @@ import { useQuery } from "@tanstack/react-query" import { CardHeader, CardTitle, CardContent } from "@/components/ui/card" import config from "@/config" -import { formatCurrency } from "@/lib/utils" -import { Package, DollarSign, ShoppingCart } from "lucide-react" // Importing icons +import { formatCurrency } from "@/utils/formatCurrency" +import { Package, DollarSign, ShoppingCart } from "lucide-react" interface ReplenishmentMetricsData { productsToReplenish: number @@ -21,55 +21,60 @@ interface ReplenishmentMetricsData { }[] } +function MetricSkeleton() { + return
; +} + export function ReplenishmentMetrics() { - const { data, error, isLoading } = useQuery({ + const { data, isError, isLoading } = useQuery({ queryKey: ["replenishment-metrics"], queryFn: async () => { const response = await fetch(`${config.apiUrl}/dashboard/replenishment/metrics`) - if (!response.ok) { - const text = await response.text(); - console.error('API Error:', text); - throw new Error(`Failed to fetch replenishment metrics: ${response.status} ${response.statusText} - ${text}`) - } - const data = await response.json(); - return data; + if (!response.ok) throw new Error('Failed to fetch replenishment metrics'); + return response.json(); }, }) - if (isLoading) return
Loading replenishment metrics...
; - if (error) return
Error: {error.message}
; - if (!data) return
No replenishment data available
; - return ( <> Replenishment -
-
-
- -

Units to Replenish

+ {isError ? ( +

Failed to load replenishment metrics

+ ) : ( +
+
+
+ +

Units to Replenish

+
+ {isLoading || !data ? : ( +

{data.unitsToReplenish.toLocaleString()}

+ )}
-

{data.unitsToReplenish.toLocaleString() || 0}

-
-
-
- -

Replenishment Cost

+
+
+ +

Replenishment Cost

+
+ {isLoading || !data ? : ( +

{formatCurrency(data.replenishmentCost)}

+ )}
-

{formatCurrency(data.replenishmentCost || 0)}

-
-
-
- -

Replenishment Retail

+
+
+ +

Replenishment Retail

+
+ {isLoading || !data ? : ( +

{formatCurrency(data.replenishmentRetail)}

+ )}
-

{formatCurrency(data.replenishmentRetail || 0)}

-
+ )} ) -} \ No newline at end of file +} diff --git a/inventory/src/components/overview/SalesMetrics.tsx b/inventory/src/components/overview/SalesMetrics.tsx index 8ccf497..d85a2a6 100644 --- a/inventory/src/components/overview/SalesMetrics.tsx +++ b/inventory/src/components/overview/SalesMetrics.tsx @@ -3,7 +3,7 @@ import { CardHeader, CardTitle, CardContent } from "@/components/ui/card" import { AreaChart, Area, ResponsiveContainer, XAxis, YAxis, Tooltip } from "recharts" import { useState } from "react" import config from "@/config" -import { formatCurrency } from "@/lib/utils" +import { formatCurrency } from "@/utils/formatCurrency" import { ClipboardList, Package, DollarSign, ShoppingCart } from "lucide-react" import { DateRange } from "react-day-picker" import { addDays, format } from "date-fns" @@ -12,23 +12,27 @@ import { DateRangePicker } from "@/components/ui/date-range-picker-narrow" interface SalesData { totalOrders: number totalUnitsSold: number - totalCogs: string - totalRevenue: string + totalCogs: number + totalRevenue: number dailySales: { date: string units: number - revenue: string - cogs: string + revenue: number + cogs: number }[] } +function MetricSkeleton() { + return
; +} + export function SalesMetrics() { const [dateRange, setDateRange] = useState({ from: addDays(new Date(), -30), to: new Date(), }); - const { data } = useQuery({ + const { data, isError, isLoading } = useQuery({ queryKey: ["sales-metrics", dateRange], queryFn: async () => { const params = new URLSearchParams({ @@ -36,9 +40,7 @@ export function SalesMetrics() { endDate: dateRange.to?.toISOString() || "", }); const response = await fetch(`${config.apiUrl}/dashboard/sales/metrics?${params}`) - if (!response.ok) { - throw new Error("Failed to fetch sales metrics") - } + if (!response.ok) throw new Error("Failed to fetch sales metrics"); return response.json() }, }) @@ -58,70 +60,90 @@ export function SalesMetrics() {
-
-
-
- -

Total Orders

+ {isError ? ( +

Failed to load sales metrics

+ ) : ( + <> +
+
+
+ +

Total Orders

+
+ {isLoading || !data ? : ( +

{data.totalOrders.toLocaleString()}

+ )} +
+
+
+ +

Units Sold

+
+ {isLoading || !data ? : ( +

{data.totalUnitsSold.toLocaleString()}

+ )} +
+
+
+ +

Cost of Goods

+
+ {isLoading || !data ? : ( +

{formatCurrency(Number(data.totalCogs))}

+ )} +
+
+
+ +

Revenue

+
+ {isLoading || !data ? : ( +

{formatCurrency(Number(data.totalRevenue))}

+ )} +
-

{data?.totalOrders.toLocaleString() || 0}

-
-
-
- -

Units Sold

-
-

{data?.totalUnitsSold.toLocaleString() || 0}

-
-
-
- -

Cost of Goods

-
-

{formatCurrency(Number(data?.totalCogs) || 0)}

-
-
-
- -

Revenue

-
-

{formatCurrency(Number(data?.totalRevenue) || 0)}

-
-
-
- - - - - [formatCurrency(Number(value)), "Revenue"]} - labelFormatter={(date) => format(new Date(date), 'MMM d, yyyy')} - /> - - - -
+
+ {isLoading ? ( +
+
+
+ ) : ( + + + + + [formatCurrency(Number(value)), "Revenue"]} + labelFormatter={(date) => format(new Date(date), 'MMM d, yyyy')} + /> + + + + )} +
+ + )} ) -} \ No newline at end of file +} diff --git a/inventory/src/components/overview/StockMetrics.tsx b/inventory/src/components/overview/StockMetrics.tsx index 06c9339..71c32ad 100644 --- a/inventory/src/components/overview/StockMetrics.tsx +++ b/inventory/src/components/overview/StockMetrics.tsx @@ -2,7 +2,7 @@ import { useQuery } from "@tanstack/react-query" import { CardHeader, CardTitle, CardContent } from "@/components/ui/card" import { PieChart, Pie, ResponsiveContainer, Cell, Sector } from "recharts" import config from "@/config" -import { formatCurrency } from "@/lib/utils" +import { formatCurrency } from "@/utils/formatCurrency" import { Package, Layers, DollarSign, ShoppingCart } from "lucide-react" import { useState } from "react" @@ -10,14 +10,14 @@ interface StockMetricsData { totalProducts: number productsInStock: number totalStockUnits: number - totalStockCost: string - totalStockRetail: string + totalStockCost: number + totalStockRetail: number brandStock: { brand: string variants: number units: number - cost: string - retail: string + cost: number + retail: number }[] } @@ -34,12 +34,12 @@ const COLORS = [ const renderActiveShape = (props: any) => { const { cx, cy, innerRadius, outerRadius, startAngle, endAngle, fill, brand, retail } = props; - + // Split brand name into words and create lines of max 12 chars const words = brand.split(' '); const lines: string[] = []; let currentLine = ''; - + words.forEach((word: string) => { if ((currentLine + ' ' + word).length <= 12) { currentLine = currentLine ? `${currentLine} ${word}` : word; @@ -71,132 +71,148 @@ const renderActiveShape = (props: any) => { fill={fill} /> {lines.map((line, i) => ( - {line} ))} - - {formatCurrency(Number(retail))} + {formatCurrency(retail)} ); }; +function MetricSkeleton() { + return
; +} + export function StockMetrics() { const [activeIndex, setActiveIndex] = useState(); - - const { data, error, isLoading } = useQuery({ + + const { data, isError, isLoading } = useQuery({ queryKey: ["stock-metrics"], queryFn: async () => { const response = await fetch(`${config.apiUrl}/dashboard/stock/metrics`); - if (!response.ok) { - const text = await response.text(); - console.error('API Error:', text); - throw new Error(`Failed to fetch stock metrics: ${response.status} ${response.statusText}`); - } - const data = await response.json(); - return data; + if (!response.ok) throw new Error('Failed to fetch stock metrics'); + return response.json(); }, }); - if (isLoading) return
Loading...
; - if (error) return
Error loading stock metrics
; - return ( <> Stock -
-
-
-
-
- -

Products

+ {isError ? ( +

Failed to load stock metrics

+ ) : ( +
+
+
+
+
+ +

Products

+
+ {isLoading || !data ? : ( +

{data.totalProducts.toLocaleString()}

+ )} +
+
+
+ +

Products In Stock

+
+ {isLoading || !data ? : ( +

{data.productsInStock.toLocaleString()}

+ )} +
+
+
+ +

Stock Units

+
+ {isLoading || !data ? : ( +

{data.totalStockUnits.toLocaleString()}

+ )} +
+
+
+ +

Stock Cost

+
+ {isLoading || !data ? : ( +

{formatCurrency(data.totalStockCost)}

+ )} +
+
+
+ +

Stock Retail

+
+ {isLoading || !data ? : ( +

{formatCurrency(data.totalStockRetail)}

+ )}
-

{data?.totalProducts.toLocaleString() || 0}

-
-
- -

Products In Stock

+
+
+
+
Stock Retail By Brand
+
+ {isLoading || !data ? ( +
+
+
+ ) : ( + + + setActiveIndex(index)} + onMouseLeave={() => setActiveIndex(undefined)} + > + {data.brandStock.map((entry, index) => ( + + ))} + + + + )}
-

{data?.productsInStock.toLocaleString() || 0}

-
-
-
- -

Stock Units

-
-

{data?.totalStockUnits.toLocaleString() || 0}

-
-
-
- -

Stock Cost

-
-

{formatCurrency(Number(data?.totalStockCost) || 0)}

-
-
-
- -

Stock Retail

-
-

{formatCurrency(Number(data?.totalStockRetail) || 0)}

-
-
-
Stock Retail By Brand
-
- - - setActiveIndex(index)} - onMouseLeave={() => setActiveIndex(undefined)} - > - {data?.brandStock?.map((entry, index) => ( - - ))} - - - -
-
-
-
+ )} ) -} \ No newline at end of file +} diff --git a/inventory/src/components/overview/TopOverstockedProducts.tsx b/inventory/src/components/overview/TopOverstockedProducts.tsx index 7de9154..d913fc2 100644 --- a/inventory/src/components/overview/TopOverstockedProducts.tsx +++ b/inventory/src/components/overview/TopOverstockedProducts.tsx @@ -3,7 +3,7 @@ import { CardHeader, CardTitle, CardContent } from "@/components/ui/card" import { ScrollArea } from "@/components/ui/scroll-area" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import config from "@/config" -import { formatCurrency } from "@/lib/utils" +import { formatCurrency } from "@/utils/formatCurrency" interface Product { pid: number; @@ -15,14 +15,22 @@ interface Product { excess_retail: number; } +function TableSkeleton() { + return ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ ))} +
+ ); +} + export function TopOverstockedProducts() { - const { data } = useQuery({ + const { data, isError, isLoading } = useQuery({ queryKey: ["top-overstocked-products"], queryFn: async () => { const response = await fetch(`${config.apiUrl}/dashboard/overstock/products?limit=50`) - if (!response.ok) { - throw new Error("Failed to fetch overstocked products") - } + if (!response.ok) throw new Error("Failed to fetch overstocked products"); return response.json() }, }) @@ -33,41 +41,47 @@ export function TopOverstockedProducts() { Top Overstocked Products - - - - - Product - Stock - Excess - Cost - Retail - - - - {data?.map((product) => ( - - - - {product.title} - -
{product.sku}
-
- {product.stock_quantity} - {product.overstocked_amt} - {formatCurrency(product.excess_cost)} - {formatCurrency(product.excess_retail)} + {isError ? ( +

Failed to load overstocked products

+ ) : isLoading ? ( + + ) : ( + +
+ + + Product + Stock + Excess + Cost + Retail - ))} - -
-
+ + + {data?.map((product) => ( + + + + {product.title} + +
{product.sku}
+
+ {product.stock_quantity} + {product.overstocked_amt} + {formatCurrency(Number(product.excess_cost))} + {formatCurrency(Number(product.excess_retail))} +
+ ))} +
+ + + )}
) -} \ No newline at end of file +} diff --git a/inventory/src/components/overview/TopReplenishProducts.tsx b/inventory/src/components/overview/TopReplenishProducts.tsx index dc50ab5..7f3144f 100644 --- a/inventory/src/components/overview/TopReplenishProducts.tsx +++ b/inventory/src/components/overview/TopReplenishProducts.tsx @@ -3,6 +3,7 @@ import { CardHeader, CardTitle, CardContent } from "@/components/ui/card" import { ScrollArea } from "@/components/ui/scroll-area" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import config from "@/config" +import { format } from "date-fns" interface Product { pid: number; @@ -14,14 +15,22 @@ interface Product { last_purchase_date: string | null; } +function TableSkeleton() { + return ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ ))} +
+ ); +} + export function TopReplenishProducts() { - const { data } = useQuery({ + const { data, isError, isLoading } = useQuery({ queryKey: ["top-replenish-products"], queryFn: async () => { const response = await fetch(`${config.apiUrl}/dashboard/replenish/products?limit=50`) - if (!response.ok) { - throw new Error("Failed to fetch products to replenish") - } + if (!response.ok) throw new Error("Failed to fetch products to replenish"); return response.json() }, }) @@ -32,41 +41,47 @@ export function TopReplenishProducts() { Top Products To Replenish - - - - - Product - Stock - Daily Sales - Reorder Qty - Last Purchase - - - - {data?.map((product) => ( - - - - {product.title} - -
{product.sku}
-
- {product.stock_quantity} - {Number(product.daily_sales_avg).toFixed(1)} - {product.reorder_qty} - {product.last_purchase_date ? product.last_purchase_date : '-'} + {isError ? ( +

Failed to load replenish products

+ ) : isLoading ? ( + + ) : ( + +
+ + + Product + Stock + Daily Sales + Reorder Qty + Last Purchase - ))} - -
-
+ + + {data?.map((product) => ( + + + + {product.title} + +
{product.sku}
+
+ {product.stock_quantity} + {Number(product.daily_sales_avg).toFixed(1)} + {product.reorder_qty} + {product.last_purchase_date ? format(new Date(product.last_purchase_date), 'M/dd/yyyy') : '-'} +
+ ))} +
+ + + )}
) -} \ No newline at end of file +} diff --git a/inventory/src/components/overview/VendorPerformance.tsx b/inventory/src/components/overview/VendorPerformance.tsx deleted file mode 100644 index 52a0443..0000000 --- a/inventory/src/components/overview/VendorPerformance.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { useQuery } from "@tanstack/react-query" -import { CardHeader, CardTitle, CardContent } from "@/components/ui/card" -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table" -import { Progress } from "@/components/ui/progress" -import config from "@/config" - -interface VendorMetrics { - vendor: string - avg_lead_time: number - on_time_delivery_rate: number - avg_fill_rate: number - total_orders: number - active_orders: number - overdue_orders: number -} - -export function VendorPerformance() { - const { data: vendors } = useQuery({ - queryKey: ["vendor-metrics"], - queryFn: async () => { - const response = await fetch(`${config.apiUrl}/dashboard/vendor/performance`) - if (!response.ok) { - throw new Error("Failed to fetch vendor metrics") - } - return response.json() - }, - }) - - // Sort vendors by on-time delivery rate - const sortedVendors = vendors - ?.sort((a, b) => b.on_time_delivery_rate - a.on_time_delivery_rate) - - return ( - <> - - Top Vendor Performance - - - - - - Vendor - On-Time - Fill Rate - - - - {sortedVendors?.map((vendor) => ( - - {vendor.vendor} - -
- - - {vendor.on_time_delivery_rate.toFixed(0)}% - -
-
- - {vendor.avg_fill_rate.toFixed(0)}% - -
- ))} -
-
-
- - ) -} \ No newline at end of file diff --git a/inventory/src/pages/Analytics.tsx b/inventory/src/pages/Analytics.tsx index b00ba38..d7301f8 100644 --- a/inventory/src/pages/Analytics.tsx +++ b/inventory/src/pages/Analytics.tsx @@ -106,7 +106,7 @@ export function Analytics() { - Avg Stock Cover + Median Stock Cover