diff --git a/inventory-server/src/routes/products.js b/inventory-server/src/routes/products.js index 338d843..2ad1b04 100755 --- a/inventory-server/src/routes/products.js +++ b/inventory-server/src/routes/products.js @@ -367,15 +367,129 @@ router.get('/trending', async (req, res) => { router.get('/:id', async (req, res) => { const pool = req.app.locals.pool; try { + // Get basic product data with metrics const [rows] = await pool.query( - 'SELECT * FROM products WHERE product_id = ? AND visible = true', + `SELECT + p.*, + GROUP_CONCAT(DISTINCT c.name) as categories, + pm.daily_sales_avg, + pm.weekly_sales_avg, + pm.monthly_sales_avg, + pm.days_of_inventory, + pm.reorder_point, + pm.safety_stock, + pm.avg_margin_percent, + pm.total_revenue, + pm.inventory_value, + pm.turnover_rate, + pm.abc_class, + pm.stock_status, + pm.avg_lead_time_days, + pm.current_lead_time, + pm.target_lead_time, + pm.lead_time_status, + pm.gmroi, + pm.cost_of_goods_sold, + pm.gross_profit + FROM products p + LEFT JOIN product_metrics pm ON p.product_id = pm.product_id + LEFT JOIN product_categories pc ON p.product_id = pc.product_id + LEFT JOIN categories c ON pc.category_id = c.id + WHERE p.product_id = ? AND p.visible = true + GROUP BY p.product_id`, [req.params.id] ); if (rows.length === 0) { return res.status(404).json({ error: 'Product not found' }); } - res.json(rows[0]); + + // Get vendor performance metrics + const [vendorMetrics] = await pool.query( + `SELECT * FROM vendor_metrics WHERE vendor = ?`, + [rows[0].vendor] + ); + + // Transform the data to match frontend expectations + const product = { + // Basic product info + product_id: rows[0].product_id, + title: rows[0].title, + SKU: rows[0].SKU, + barcode: rows[0].barcode, + created_at: rows[0].created_at, + updated_at: rows[0].updated_at, + + // Inventory fields + stock_quantity: parseInt(rows[0].stock_quantity), + moq: parseInt(rows[0].moq), + uom: parseInt(rows[0].uom), + managing_stock: Boolean(rows[0].managing_stock), + replenishable: Boolean(rows[0].replenishable), + + // Pricing fields + price: parseFloat(rows[0].price), + regular_price: parseFloat(rows[0].regular_price), + cost_price: parseFloat(rows[0].cost_price), + landing_cost_price: parseFloat(rows[0].landing_cost_price), + + // Categorization + categories: rows[0].categories ? rows[0].categories.split(',') : [], + tags: rows[0].tags ? rows[0].tags.split(',') : [], + options: rows[0].options ? JSON.parse(rows[0].options) : {}, + + // Vendor info + vendor: rows[0].vendor, + vendor_reference: rows[0].vendor_reference, + brand: rows[0].brand, + + // URLs + permalink: rows[0].permalink, + image: rows[0].image, + + // Metrics + metrics: { + // Sales metrics + daily_sales_avg: parseFloat(rows[0].daily_sales_avg) || 0, + weekly_sales_avg: parseFloat(rows[0].weekly_sales_avg) || 0, + monthly_sales_avg: parseFloat(rows[0].monthly_sales_avg) || 0, + + // Inventory metrics + days_of_inventory: parseInt(rows[0].days_of_inventory) || 0, + reorder_point: parseInt(rows[0].reorder_point) || 0, + safety_stock: parseInt(rows[0].safety_stock) || 0, + stock_status: rows[0].stock_status || 'Unknown', + abc_class: rows[0].abc_class || 'C', + + // Financial metrics + avg_margin_percent: parseFloat(rows[0].avg_margin_percent) || 0, + total_revenue: parseFloat(rows[0].total_revenue) || 0, + inventory_value: parseFloat(rows[0].inventory_value) || 0, + turnover_rate: parseFloat(rows[0].turnover_rate) || 0, + gmroi: parseFloat(rows[0].gmroi) || 0, + cost_of_goods_sold: parseFloat(rows[0].cost_of_goods_sold) || 0, + gross_profit: parseFloat(rows[0].gross_profit) || 0, + + // Lead time metrics + avg_lead_time_days: parseInt(rows[0].avg_lead_time_days) || 0, + current_lead_time: parseInt(rows[0].current_lead_time) || 0, + target_lead_time: parseInt(rows[0].target_lead_time) || 14, + lead_time_status: rows[0].lead_time_status || 'Unknown' + }, + + // Vendor performance (if available) + vendor_performance: vendorMetrics.length ? { + avg_lead_time_days: parseFloat(vendorMetrics[0].avg_lead_time_days) || 0, + on_time_delivery_rate: parseFloat(vendorMetrics[0].on_time_delivery_rate) || 0, + order_fill_rate: parseFloat(vendorMetrics[0].order_fill_rate) || 0, + total_orders: parseInt(vendorMetrics[0].total_orders) || 0, + total_late_orders: parseInt(vendorMetrics[0].total_late_orders) || 0, + total_purchase_value: parseFloat(vendorMetrics[0].total_purchase_value) || 0, + avg_order_value: parseFloat(vendorMetrics[0].avg_order_value) || 0 + } : null + }; + + res.json(product); } catch (error) { console.error('Error fetching product:', error); res.status(500).json({ error: 'Failed to fetch product' }); @@ -458,4 +572,202 @@ router.put('/:id', async (req, res) => { } }); +// Get product metrics +router.get('/:id/metrics', async (req, res) => { + const pool = req.app.locals.pool; + try { + const { id } = req.params; + + // Get metrics from product_metrics table with inventory health data + const [metrics] = await pool.query(` + WITH inventory_status AS ( + SELECT + p.product_id, + CASE + WHEN pm.daily_sales_avg = 0 THEN 'New' + WHEN p.stock_quantity <= CEIL(pm.daily_sales_avg * 7) THEN 'Critical' + WHEN p.stock_quantity <= CEIL(pm.daily_sales_avg * 14) THEN 'Reorder' + WHEN p.stock_quantity > (pm.daily_sales_avg * 90) THEN 'Overstocked' + ELSE 'Healthy' + END as calculated_status + FROM products p + LEFT JOIN product_metrics pm ON p.product_id = pm.product_id + WHERE p.product_id = ? + ) + SELECT + COALESCE(pm.daily_sales_avg, 0) as daily_sales_avg, + COALESCE(pm.weekly_sales_avg, 0) as weekly_sales_avg, + COALESCE(pm.monthly_sales_avg, 0) as monthly_sales_avg, + COALESCE(pm.days_of_inventory, 0) as days_of_inventory, + COALESCE(pm.reorder_point, CEIL(COALESCE(pm.daily_sales_avg, 0) * 14)) as reorder_point, + COALESCE(pm.safety_stock, CEIL(COALESCE(pm.daily_sales_avg, 0) * 7)) as safety_stock, + COALESCE(pm.avg_margin_percent, + ((p.price - COALESCE(p.cost_price, 0)) / NULLIF(p.price, 0)) * 100 + ) as avg_margin_percent, + COALESCE(pm.total_revenue, 0) as total_revenue, + COALESCE(pm.inventory_value, p.stock_quantity * COALESCE(p.cost_price, 0)) as inventory_value, + COALESCE(pm.turnover_rate, 0) as turnover_rate, + COALESCE(pm.abc_class, 'C') as abc_class, + COALESCE(pm.stock_status, is.calculated_status) as stock_status, + COALESCE(pm.avg_lead_time_days, 0) as avg_lead_time_days, + COALESCE(pm.current_lead_time, 0) as current_lead_time, + COALESCE(pm.target_lead_time, 14) as target_lead_time, + COALESCE(pm.lead_time_status, 'Unknown') as lead_time_status + FROM products p + LEFT JOIN product_metrics pm ON p.product_id = pm.product_id + LEFT JOIN inventory_status is ON p.product_id = is.product_id + WHERE p.product_id = ? + `, [id, id]); + + if (!metrics.length) { + // Return default metrics structure if no data found + res.json({ + daily_sales_avg: 0, + weekly_sales_avg: 0, + monthly_sales_avg: 0, + days_of_inventory: 0, + reorder_point: 0, + safety_stock: 0, + avg_margin_percent: 0, + total_revenue: 0, + inventory_value: 0, + turnover_rate: 0, + abc_class: 'C', + stock_status: 'New', + avg_lead_time_days: 0, + current_lead_time: 0, + target_lead_time: 14, + lead_time_status: 'Unknown' + }); + return; + } + + res.json(metrics[0]); + } catch (error) { + console.error('Error fetching product metrics:', error); + res.status(500).json({ error: 'Failed to fetch product metrics' }); + } +}); + +// Get product time series data +router.get('/:id/time-series', async (req, res) => { + const pool = req.app.locals.pool; + try { + const { id } = req.params; + const months = parseInt(req.query.months) || 12; + + // Get monthly sales data with running totals and growth rates + const [monthlySales] = await pool.query(` + WITH monthly_data AS ( + SELECT + CONCAT(year, '-', LPAD(month, 2, '0')) as month, + total_quantity_sold as quantity, + total_revenue as revenue, + total_cost as cost, + avg_price, + profit_margin, + inventory_value + FROM product_time_aggregates + WHERE product_id = ? + ORDER BY year DESC, month DESC + LIMIT ? + ) + SELECT + month, + quantity, + revenue, + cost, + avg_price, + profit_margin, + inventory_value, + LAG(quantity) OVER (ORDER BY month) as prev_month_quantity, + LAG(revenue) OVER (ORDER BY month) as prev_month_revenue + FROM monthly_data + ORDER BY month ASC + `, [id, months]); + + // Calculate growth rates and format data + const formattedMonthlySales = monthlySales.map(row => ({ + month: row.month, + quantity: parseInt(row.quantity) || 0, + revenue: parseFloat(row.revenue) || 0, + cost: parseFloat(row.cost) || 0, + avg_price: parseFloat(row.avg_price) || 0, + profit_margin: parseFloat(row.profit_margin) || 0, + inventory_value: parseFloat(row.inventory_value) || 0, + quantity_growth: row.prev_month_quantity ? + ((row.quantity - row.prev_month_quantity) / row.prev_month_quantity) * 100 : 0, + revenue_growth: row.prev_month_revenue ? + ((row.revenue - row.prev_month_revenue) / row.prev_month_revenue) * 100 : 0 + })); + + // Get recent orders with customer info and status + const [recentOrders] = await pool.query(` + SELECT + DATE_FORMAT(date, '%Y-%m-%d') as date, + order_number, + quantity, + price, + discount, + tax, + shipping, + customer, + status, + payment_method + FROM orders + WHERE product_id = ? + AND canceled = false + ORDER BY date DESC + LIMIT 10 + `, [id]); + + // Get recent purchase orders with detailed status + const [recentPurchases] = await pool.query(` + SELECT + DATE_FORMAT(date, '%Y-%m-%d') as date, + DATE_FORMAT(expected_date, '%Y-%m-%d') as expected_date, + DATE_FORMAT(received_date, '%Y-%m-%d') as received_date, + po_id, + ordered, + received, + status, + cost_price, + notes, + CASE + WHEN received_date IS NOT NULL THEN + DATEDIFF(received_date, date) + WHEN expected_date < CURDATE() AND status != 'received' THEN + DATEDIFF(CURDATE(), expected_date) + ELSE NULL + END as lead_time_days + FROM purchase_orders + WHERE product_id = ? + ORDER BY date DESC + LIMIT 10 + `, [id]); + + res.json({ + monthly_sales: formattedMonthlySales, + recent_orders: recentOrders.map(order => ({ + ...order, + price: parseFloat(order.price), + discount: parseFloat(order.discount), + tax: parseFloat(order.tax), + shipping: parseFloat(order.shipping), + quantity: parseInt(order.quantity) + })), + recent_purchases: recentPurchases.map(po => ({ + ...po, + ordered: parseInt(po.ordered), + received: parseInt(po.received), + cost_price: parseFloat(po.cost_price), + lead_time_days: po.lead_time_days ? parseInt(po.lead_time_days) : null + })) + }); + } catch (error) { + console.error('Error fetching product time series:', error); + res.status(500).json({ error: 'Failed to fetch product time series' }); + } +}); + module.exports = router; \ No newline at end of file diff --git a/inventory/src/components/products/ProductDetail.tsx b/inventory/src/components/products/ProductDetail.tsx index 87b4c84..ca0091a 100644 --- a/inventory/src/components/products/ProductDetail.tsx +++ b/inventory/src/components/products/ProductDetail.tsx @@ -14,47 +14,118 @@ import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContai import config from "@/config"; interface Product { - product_id: string; + product_id: number; title: string; - sku: string; + SKU: string; + barcode: string; + created_at: string; + updated_at: string; + + // Inventory fields stock_quantity: number; - price: number; - regular_price: number; - cost_price: number; - vendor: string; - brand: string; + moq: number; + uom: number; + managing_stock: boolean; + replenishable: boolean; + + // Pricing fields + price: string | number; + regular_price: string | number; + cost_price: string | number; + landing_cost_price: string | number | null; + + // Categorization categories: string[]; + tags: string[]; + options: Record; + + // Vendor info + vendor: string; + vendor_reference: string; + brand: string; + + // URLs + permalink: string; + image: string; + // Metrics - daily_sales_avg: number; - weekly_sales_avg: number; - monthly_sales_avg: number; - days_of_inventory: number; - reorder_point: number; - safety_stock: number; - avg_margin_percent: number; - total_revenue: number; - inventory_value: number; - turnover_rate: number; - abc_class: string; - stock_status: string; + metrics: { + // Sales metrics + daily_sales_avg: number; + weekly_sales_avg: number; + monthly_sales_avg: number; + + // Inventory metrics + days_of_inventory: number; + reorder_point: number; + safety_stock: number; + stock_status: string; + abc_class: string; + + // Financial metrics + avg_margin_percent: number; + total_revenue: number; + inventory_value: number; + turnover_rate: number; + gmroi: number; + cost_of_goods_sold: number; + gross_profit: number; + + // Lead time metrics + avg_lead_time_days: number; + current_lead_time: number; + target_lead_time: number; + lead_time_status: string; + }; + + // Vendor performance + vendor_performance?: { + avg_lead_time_days: number; + on_time_delivery_rate: number; + order_fill_rate: number; + total_orders: number; + total_late_orders: number; + total_purchase_value: number; + avg_order_value: number; + }; + // Time series data - monthly_sales: Array<{ + monthly_sales?: Array<{ month: string; quantity: number; revenue: number; + cost: number; + avg_price: number; + profit_margin: number; + inventory_value: number; + quantity_growth: number; + revenue_growth: number; }>; - recent_orders: Array<{ + + recent_orders?: Array<{ date: string; order_number: string; quantity: number; price: number; + discount: number; + tax: number; + shipping: number; + customer: string; + status: string; + payment_method: string; }>; - recent_purchases: Array<{ + + recent_purchases?: Array<{ date: string; + expected_date: string; + received_date: string | null; po_id: string; ordered: number; received: number; status: string; + cost_price: number; + notes: string; + lead_time_days: number | null; }>; } @@ -64,24 +135,61 @@ interface ProductDetailProps { } export function ProductDetail({ productId, onClose }: ProductDetailProps) { - const { data: product, isLoading } = useQuery({ + const { data: product, isLoading: isLoadingProduct } = useQuery({ queryKey: ["product", productId], queryFn: async () => { if (!productId) return null; + console.log('Fetching product details for:', productId); + const response = await fetch(`${config.apiUrl}/products/${productId}`); if (!response.ok) { throw new Error("Failed to fetch product details"); } - return response.json(); + const data = await response.json(); + console.log('Product data:', data); + return data; }, enabled: !!productId, }); + // Separate query for time series data + const { data: timeSeriesData, isLoading: isLoadingTimeSeries } = useQuery({ + queryKey: ["product-time-series", productId], + queryFn: async () => { + if (!productId) return null; + const response = await fetch(`${config.apiUrl}/products/${productId}/time-series`); + if (!response.ok) { + throw new Error("Failed to fetch time series data"); + } + const data = await response.json(); + console.log('Time series data:', data); + return data; + }, + enabled: !!productId, + }); + + const isLoading = isLoadingProduct || isLoadingTimeSeries; + + // Helper function to format price values + const formatPrice = (price: string | number | null | undefined): string => { + if (price === null || price === undefined) return 'N/A'; + const numericPrice = typeof price === 'string' ? parseFloat(price) : price; + return typeof numericPrice === 'number' ? numericPrice.toFixed(2) : 'N/A'; + }; + + // Combine product and time series data + const combinedData = product && timeSeriesData ? { + ...product, + monthly_sales: timeSeriesData.monthly_sales, + recent_orders: timeSeriesData.recent_orders, + recent_purchases: timeSeriesData.recent_purchases + } : product; + if (!productId) return null; return ( !open && onClose()}> - + {isLoading ? ( @@ -92,21 +200,22 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) { {isLoading ? ( - "\u00A0" // Non-breaking space for loading state + "\u00A0" ) : ( - `SKU: ${product?.sku}` + `SKU: ${product?.SKU} | Stock: ${product?.stock_quantity}` )} -
+
- + Overview Inventory Sales Purchase History - Performance Metrics + Financial + Vendor @@ -130,7 +239,23 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
Categories
-
{Array.isArray(product?.categories) ? product.categories.join(", ") : "N/A"}
+
+ {product?.categories?.map(category => ( + + {category} + + )) || "N/A"} +
+
+
+
Tags
+
+ {product?.tags?.map(tag => ( + + {tag} + + )) || "N/A"} +
@@ -140,18 +265,74 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
Price
-
${typeof product?.price === 'number' ? product.price.toFixed(2) : 'N/A'}
+
${formatPrice(product?.price)}
Regular Price
-
${typeof product?.regular_price === 'number' ? product.regular_price.toFixed(2) : 'N/A'}
+
${formatPrice(product?.regular_price)}
Cost Price
-
${typeof product?.cost_price === 'number' ? product.cost_price.toFixed(2) : 'N/A'}
+
${formatPrice(product?.cost_price)}
+
+
+
Landing Cost
+
${formatPrice(product?.landing_cost_price)}
+ + +

Stock Status

+
+
+
Current Stock
+
{product?.stock_quantity}
+
+
+
Status
+
{product?.metrics?.stock_status}
+
+
+
Days of Stock
+
{product?.metrics?.days_of_inventory} days
+
+
+
+ + +

Sales Velocity

+
+
+
Daily Sales
+
{product?.metrics?.daily_sales_avg?.toFixed(1)} units
+
+
+
Weekly Sales
+
{product?.metrics?.weekly_sales_avg?.toFixed(1)} units
+
+
+
Monthly Sales
+
{product?.metrics?.monthly_sales_avg?.toFixed(1)} units
+
+
+
+ + +

Sales Trend

+
+ + + + + + + + + + +
+
)} @@ -170,11 +351,11 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
Days of Inventory
-
{product?.days_of_inventory || 0}
+
{product?.metrics?.days_of_inventory || 0}
Status
-
{product?.stock_status || "N/A"}
+
{product?.metrics?.stock_status || "N/A"}
@@ -184,15 +365,15 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
Reorder Point
-
{product?.reorder_point || 0}
+
{product?.metrics?.reorder_point || 0}
Safety Stock
-
{product?.safety_stock || 0}
+
{product?.metrics?.safety_stock || 0}
ABC Class
-
{product?.abc_class || "N/A"}
+
{product?.metrics?.abc_class || "N/A"}
@@ -206,28 +387,45 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) { ) : (
-

Sales Metrics

-
-
-
Daily Sales Avg
-
{product?.daily_sales_avg?.toFixed(2) || 0}
-
-
-
Weekly Sales Avg
-
{product?.weekly_sales_avg?.toFixed(2) || 0}
-
-
-
Monthly Sales Avg
-
{product?.monthly_sales_avg?.toFixed(2) || 0}
-
-
+

Recent Orders

+ + + + Date + Order # + Customer + Quantity + Price + Status + + + + {combinedData?.recent_orders?.map((order: NonNullable[number]) => ( + + {order.date} + {order.order_number} + {order.customer} + {order.quantity} + ${formatPrice(order.price)} + {order.status} + + ))} + {(!combinedData?.recent_orders || combinedData.recent_orders.length === 0) && ( + + + No recent orders + + + )} + +

Monthly Sales Trend

- + @@ -239,30 +437,6 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
- - -

Recent Orders

- - - - Date - Order # - Quantity - Price - - - - {product?.recent_orders?.map((order) => ( - - {order.date} - {order.order_number} - {order.quantity} - ${order.price.toFixed(2)} - - ))} - -
-
)} @@ -282,18 +456,27 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) { Ordered Received Status + Lead Time - {product?.recent_purchases?.map((po) => ( + {combinedData?.recent_purchases?.map((po: NonNullable[number]) => ( {po.date} {po.po_id} {po.ordered} {po.received} {po.status} + {po.lead_time_days ? `${po.lead_time_days} days` : 'N/A'} ))} + {(!combinedData?.recent_purchases || combinedData.recent_purchases.length === 0) && ( + + + No recent purchase orders + + + )} @@ -301,47 +484,108 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) { )} - + {isLoading ? ( ) : ( -
+
-

Financial Metrics

-
+

Financial Overview

+
-
Total Revenue
-
${product?.total_revenue?.toFixed(2) || '0.00'}
+
Gross Profit
+
${formatPrice(product?.metrics.gross_profit)}
+
+
+
GMROI
+
{product?.metrics.gmroi.toFixed(2)}
Margin %
-
{product?.avg_margin_percent?.toFixed(2) || '0.00'}%
-
-
-
Inventory Value
-
${product?.inventory_value?.toFixed(2) || '0.00'}
+
{product?.metrics.avg_margin_percent.toFixed(2)}%
-

Performance Metrics

-
+

Cost Breakdown

+
-
Turnover Rate
-
{product?.turnover_rate?.toFixed(2) || '0.00'}
+
Cost of Goods Sold
+
${formatPrice(product?.metrics.cost_of_goods_sold)}
-
ABC Classification
-
Class {product?.abc_class || 'N/A'}
+
Landing Cost
+
${formatPrice(product?.landing_cost_price)}
+
+
+ + + +

Profit Margin Trend

+
+ + + + + + + + + +
+
+
+ )} + + + + {isLoading ? ( + + ) : product?.vendor_performance ? ( +
+ +

Vendor Performance

+
+
+
On-Time Delivery
+
{product.vendor_performance.on_time_delivery_rate.toFixed(1)}%
-
Stock Status
-
{product?.stock_status || 'N/A'}
+
Order Fill Rate
+
{product.vendor_performance.order_fill_rate.toFixed(1)}%
+
+
+
Avg Lead Time
+
{product.vendor_performance.avg_lead_time_days} days
+
+
+
+ + +

Order History

+
+
+
Total Orders
+
{product.vendor_performance.total_orders}
+
+
+
Late Orders
+
{product.vendor_performance.total_late_orders}
+
+
+
Total Purchase Value
+
${formatPrice(product.vendor_performance.total_purchase_value)}
+
+
+
Avg Order Value
+
${formatPrice(product.vendor_performance.avg_order_value)}
+ ) : ( +
No vendor performance data available
)}