Fix data in product detail
This commit is contained in:
@@ -367,15 +367,129 @@ router.get('/trending', async (req, res) => {
|
|||||||
router.get('/:id', async (req, res) => {
|
router.get('/:id', async (req, res) => {
|
||||||
const pool = req.app.locals.pool;
|
const pool = req.app.locals.pool;
|
||||||
try {
|
try {
|
||||||
|
// Get basic product data with metrics
|
||||||
const [rows] = await pool.query(
|
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]
|
[req.params.id]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
return res.status(404).json({ error: 'Product not found' });
|
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) {
|
} catch (error) {
|
||||||
console.error('Error fetching product:', error);
|
console.error('Error fetching product:', error);
|
||||||
res.status(500).json({ error: 'Failed to fetch product' });
|
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;
|
module.exports = router;
|
||||||
@@ -14,47 +14,118 @@ import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContai
|
|||||||
import config from "@/config";
|
import config from "@/config";
|
||||||
|
|
||||||
interface Product {
|
interface Product {
|
||||||
product_id: string;
|
product_id: number;
|
||||||
title: string;
|
title: string;
|
||||||
sku: string;
|
SKU: string;
|
||||||
|
barcode: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
|
||||||
|
// Inventory fields
|
||||||
stock_quantity: number;
|
stock_quantity: number;
|
||||||
price: number;
|
moq: number;
|
||||||
regular_price: number;
|
uom: number;
|
||||||
cost_price: number;
|
managing_stock: boolean;
|
||||||
vendor: string;
|
replenishable: boolean;
|
||||||
brand: string;
|
|
||||||
|
// Pricing fields
|
||||||
|
price: string | number;
|
||||||
|
regular_price: string | number;
|
||||||
|
cost_price: string | number;
|
||||||
|
landing_cost_price: string | number | null;
|
||||||
|
|
||||||
|
// Categorization
|
||||||
categories: string[];
|
categories: string[];
|
||||||
|
tags: string[];
|
||||||
|
options: Record<string, any>;
|
||||||
|
|
||||||
|
// Vendor info
|
||||||
|
vendor: string;
|
||||||
|
vendor_reference: string;
|
||||||
|
brand: string;
|
||||||
|
|
||||||
|
// URLs
|
||||||
|
permalink: string;
|
||||||
|
image: string;
|
||||||
|
|
||||||
// Metrics
|
// Metrics
|
||||||
daily_sales_avg: number;
|
metrics: {
|
||||||
weekly_sales_avg: number;
|
// Sales metrics
|
||||||
monthly_sales_avg: number;
|
daily_sales_avg: number;
|
||||||
days_of_inventory: number;
|
weekly_sales_avg: number;
|
||||||
reorder_point: number;
|
monthly_sales_avg: number;
|
||||||
safety_stock: number;
|
|
||||||
avg_margin_percent: number;
|
// Inventory metrics
|
||||||
total_revenue: number;
|
days_of_inventory: number;
|
||||||
inventory_value: number;
|
reorder_point: number;
|
||||||
turnover_rate: number;
|
safety_stock: number;
|
||||||
abc_class: string;
|
stock_status: string;
|
||||||
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
|
// Time series data
|
||||||
monthly_sales: Array<{
|
monthly_sales?: Array<{
|
||||||
month: string;
|
month: string;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
revenue: 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;
|
date: string;
|
||||||
order_number: string;
|
order_number: string;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
price: number;
|
price: number;
|
||||||
|
discount: number;
|
||||||
|
tax: number;
|
||||||
|
shipping: number;
|
||||||
|
customer: string;
|
||||||
|
status: string;
|
||||||
|
payment_method: string;
|
||||||
}>;
|
}>;
|
||||||
recent_purchases: Array<{
|
|
||||||
|
recent_purchases?: Array<{
|
||||||
date: string;
|
date: string;
|
||||||
|
expected_date: string;
|
||||||
|
received_date: string | null;
|
||||||
po_id: string;
|
po_id: string;
|
||||||
ordered: number;
|
ordered: number;
|
||||||
received: number;
|
received: number;
|
||||||
status: string;
|
status: string;
|
||||||
|
cost_price: number;
|
||||||
|
notes: string;
|
||||||
|
lead_time_days: number | null;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,24 +135,61 @@ interface ProductDetailProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
||||||
const { data: product, isLoading } = useQuery<Product>({
|
const { data: product, isLoading: isLoadingProduct } = useQuery<Product>({
|
||||||
queryKey: ["product", productId],
|
queryKey: ["product", productId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!productId) return null;
|
if (!productId) return null;
|
||||||
|
console.log('Fetching product details for:', productId);
|
||||||
|
|
||||||
const response = await fetch(`${config.apiUrl}/products/${productId}`);
|
const response = await fetch(`${config.apiUrl}/products/${productId}`);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error("Failed to fetch product details");
|
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,
|
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;
|
if (!productId) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer open={!!productId} onOpenChange={(open) => !open && onClose()}>
|
<Drawer open={!!productId} onOpenChange={(open) => !open && onClose()}>
|
||||||
<DrawerContent className="h-[85vh]">
|
<DrawerContent className="h-[90vh] md:h-[90vh] overflow-y-auto">
|
||||||
<DrawerHeader>
|
<DrawerHeader>
|
||||||
<DrawerTitle>
|
<DrawerTitle>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
@@ -92,21 +200,22 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
|||||||
</DrawerTitle>
|
</DrawerTitle>
|
||||||
<DrawerDescription>
|
<DrawerDescription>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
"\u00A0" // Non-breaking space for loading state
|
"\u00A0"
|
||||||
) : (
|
) : (
|
||||||
`SKU: ${product?.sku}`
|
`SKU: ${product?.SKU} | Stock: ${product?.stock_quantity}`
|
||||||
)}
|
)}
|
||||||
</DrawerDescription>
|
</DrawerDescription>
|
||||||
</DrawerHeader>
|
</DrawerHeader>
|
||||||
|
|
||||||
<div className="px-4">
|
<div className="px-4 pb-8">
|
||||||
<Tabs defaultValue="overview" className="w-full">
|
<Tabs defaultValue="overview" className="w-full">
|
||||||
<TabsList className="w-full justify-start">
|
<TabsList className="w-full justify-start mb-4 sticky top-0 bg-background z-10">
|
||||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||||
<TabsTrigger value="inventory">Inventory</TabsTrigger>
|
<TabsTrigger value="inventory">Inventory</TabsTrigger>
|
||||||
<TabsTrigger value="sales">Sales</TabsTrigger>
|
<TabsTrigger value="sales">Sales</TabsTrigger>
|
||||||
<TabsTrigger value="purchase">Purchase History</TabsTrigger>
|
<TabsTrigger value="purchase">Purchase History</TabsTrigger>
|
||||||
<TabsTrigger value="metrics">Performance Metrics</TabsTrigger>
|
<TabsTrigger value="financial">Financial</TabsTrigger>
|
||||||
|
<TabsTrigger value="vendor">Vendor</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="overview" className="space-y-4">
|
<TabsContent value="overview" className="space-y-4">
|
||||||
@@ -130,7 +239,23 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm text-muted-foreground">Categories</dt>
|
<dt className="text-sm text-muted-foreground">Categories</dt>
|
||||||
<dd>{Array.isArray(product?.categories) ? product.categories.join(", ") : "N/A"}</dd>
|
<dd className="flex flex-wrap gap-2">
|
||||||
|
{product?.categories?.map(category => (
|
||||||
|
<span key={category} className="inline-flex items-center rounded-md bg-muted px-2 py-1 text-xs font-medium ring-1 ring-inset ring-muted">
|
||||||
|
{category}
|
||||||
|
</span>
|
||||||
|
)) || "N/A"}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm text-muted-foreground">Tags</dt>
|
||||||
|
<dd className="flex flex-wrap gap-2">
|
||||||
|
{product?.tags?.map(tag => (
|
||||||
|
<span key={tag} className="inline-flex items-center rounded-md bg-muted px-2 py-1 text-xs font-medium ring-1 ring-inset ring-muted">
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
)) || "N/A"}
|
||||||
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -140,18 +265,74 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
|||||||
<dl className="space-y-2">
|
<dl className="space-y-2">
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm text-muted-foreground">Price</dt>
|
<dt className="text-sm text-muted-foreground">Price</dt>
|
||||||
<dd>${typeof product?.price === 'number' ? product.price.toFixed(2) : 'N/A'}</dd>
|
<dd>${formatPrice(product?.price)}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm text-muted-foreground">Regular Price</dt>
|
<dt className="text-sm text-muted-foreground">Regular Price</dt>
|
||||||
<dd>${typeof product?.regular_price === 'number' ? product.regular_price.toFixed(2) : 'N/A'}</dd>
|
<dd>${formatPrice(product?.regular_price)}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm text-muted-foreground">Cost Price</dt>
|
<dt className="text-sm text-muted-foreground">Cost Price</dt>
|
||||||
<dd>${typeof product?.cost_price === 'number' ? product.cost_price.toFixed(2) : 'N/A'}</dd>
|
<dd>${formatPrice(product?.cost_price)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm text-muted-foreground">Landing Cost</dt>
|
||||||
|
<dd>${formatPrice(product?.landing_cost_price)}</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card className="p-4">
|
||||||
|
<h3 className="font-semibold mb-2">Stock Status</h3>
|
||||||
|
<dl className="space-y-2">
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm text-muted-foreground">Current Stock</dt>
|
||||||
|
<dd className="text-2xl font-semibold">{product?.stock_quantity}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm text-muted-foreground">Status</dt>
|
||||||
|
<dd>{product?.metrics?.stock_status}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm text-muted-foreground">Days of Stock</dt>
|
||||||
|
<dd>{product?.metrics?.days_of_inventory} days</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="p-4">
|
||||||
|
<h3 className="font-semibold mb-2">Sales Velocity</h3>
|
||||||
|
<dl className="space-y-2">
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm text-muted-foreground">Daily Sales</dt>
|
||||||
|
<dd>{product?.metrics?.daily_sales_avg?.toFixed(1)} units</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm text-muted-foreground">Weekly Sales</dt>
|
||||||
|
<dd>{product?.metrics?.weekly_sales_avg?.toFixed(1)} units</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm text-muted-foreground">Monthly Sales</dt>
|
||||||
|
<dd>{product?.metrics?.monthly_sales_avg?.toFixed(1)} units</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="p-4">
|
||||||
|
<h3 className="font-semibold mb-2">Sales Trend</h3>
|
||||||
|
<div className="h-64">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<LineChart data={combinedData?.monthly_sales || []}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="month" />
|
||||||
|
<YAxis />
|
||||||
|
<Tooltip />
|
||||||
|
<Line type="monotone" dataKey="quantity" stroke="#8884d8" name="Quantity" />
|
||||||
|
<Line type="monotone" dataKey="revenue" stroke="#82ca9d" name="Revenue" />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -170,11 +351,11 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm text-muted-foreground">Days of Inventory</dt>
|
<dt className="text-sm text-muted-foreground">Days of Inventory</dt>
|
||||||
<dd className="text-2xl font-semibold">{product?.days_of_inventory || 0}</dd>
|
<dd className="text-2xl font-semibold">{product?.metrics?.days_of_inventory || 0}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm text-muted-foreground">Status</dt>
|
<dt className="text-sm text-muted-foreground">Status</dt>
|
||||||
<dd className="text-2xl font-semibold">{product?.stock_status || "N/A"}</dd>
|
<dd className="text-2xl font-semibold">{product?.metrics?.stock_status || "N/A"}</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -184,15 +365,15 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
|||||||
<dl className="grid grid-cols-3 gap-4">
|
<dl className="grid grid-cols-3 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm text-muted-foreground">Reorder Point</dt>
|
<dt className="text-sm text-muted-foreground">Reorder Point</dt>
|
||||||
<dd>{product?.reorder_point || 0}</dd>
|
<dd>{product?.metrics?.reorder_point || 0}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm text-muted-foreground">Safety Stock</dt>
|
<dt className="text-sm text-muted-foreground">Safety Stock</dt>
|
||||||
<dd>{product?.safety_stock || 0}</dd>
|
<dd>{product?.metrics?.safety_stock || 0}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm text-muted-foreground">ABC Class</dt>
|
<dt className="text-sm text-muted-foreground">ABC Class</dt>
|
||||||
<dd>{product?.abc_class || "N/A"}</dd>
|
<dd>{product?.metrics?.abc_class || "N/A"}</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -206,28 +387,45 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Card className="p-4">
|
<Card className="p-4">
|
||||||
<h3 className="font-semibold mb-2">Sales Metrics</h3>
|
<h3 className="font-semibold mb-2">Recent Orders</h3>
|
||||||
<dl className="grid grid-cols-3 gap-4">
|
<Table>
|
||||||
<div>
|
<TableHeader>
|
||||||
<dt className="text-sm text-muted-foreground">Daily Sales Avg</dt>
|
<TableRow>
|
||||||
<dd>{product?.daily_sales_avg?.toFixed(2) || 0}</dd>
|
<TableHead>Date</TableHead>
|
||||||
</div>
|
<TableHead>Order #</TableHead>
|
||||||
<div>
|
<TableHead>Customer</TableHead>
|
||||||
<dt className="text-sm text-muted-foreground">Weekly Sales Avg</dt>
|
<TableHead>Quantity</TableHead>
|
||||||
<dd>{product?.weekly_sales_avg?.toFixed(2) || 0}</dd>
|
<TableHead>Price</TableHead>
|
||||||
</div>
|
<TableHead>Status</TableHead>
|
||||||
<div>
|
</TableRow>
|
||||||
<dt className="text-sm text-muted-foreground">Monthly Sales Avg</dt>
|
</TableHeader>
|
||||||
<dd>{product?.monthly_sales_avg?.toFixed(2) || 0}</dd>
|
<TableBody>
|
||||||
</div>
|
{combinedData?.recent_orders?.map((order: NonNullable<Product['recent_orders']>[number]) => (
|
||||||
</dl>
|
<TableRow key={order.order_number}>
|
||||||
|
<TableCell>{order.date}</TableCell>
|
||||||
|
<TableCell>{order.order_number}</TableCell>
|
||||||
|
<TableCell>{order.customer}</TableCell>
|
||||||
|
<TableCell>{order.quantity}</TableCell>
|
||||||
|
<TableCell>${formatPrice(order.price)}</TableCell>
|
||||||
|
<TableCell>{order.status}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{(!combinedData?.recent_orders || combinedData.recent_orders.length === 0) && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="text-center text-muted-foreground">
|
||||||
|
No recent orders
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="p-4">
|
<Card className="p-4">
|
||||||
<h3 className="font-semibold mb-2">Monthly Sales Trend</h3>
|
<h3 className="font-semibold mb-2">Monthly Sales Trend</h3>
|
||||||
<div className="h-64">
|
<div className="h-64">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<LineChart data={product?.monthly_sales || []}>
|
<LineChart data={combinedData?.monthly_sales || []}>
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
<XAxis dataKey="month" />
|
<XAxis dataKey="month" />
|
||||||
<YAxis yAxisId="left" />
|
<YAxis yAxisId="left" />
|
||||||
@@ -239,30 +437,6 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
|||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="p-4">
|
|
||||||
<h3 className="font-semibold mb-2">Recent Orders</h3>
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Date</TableHead>
|
|
||||||
<TableHead>Order #</TableHead>
|
|
||||||
<TableHead>Quantity</TableHead>
|
|
||||||
<TableHead>Price</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{product?.recent_orders?.map((order) => (
|
|
||||||
<TableRow key={order.order_number}>
|
|
||||||
<TableCell>{order.date}</TableCell>
|
|
||||||
<TableCell>{order.order_number}</TableCell>
|
|
||||||
<TableCell>{order.quantity}</TableCell>
|
|
||||||
<TableCell>${order.price.toFixed(2)}</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -282,18 +456,27 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
|||||||
<TableHead>Ordered</TableHead>
|
<TableHead>Ordered</TableHead>
|
||||||
<TableHead>Received</TableHead>
|
<TableHead>Received</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Lead Time</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{product?.recent_purchases?.map((po) => (
|
{combinedData?.recent_purchases?.map((po: NonNullable<Product['recent_purchases']>[number]) => (
|
||||||
<TableRow key={po.po_id}>
|
<TableRow key={po.po_id}>
|
||||||
<TableCell>{po.date}</TableCell>
|
<TableCell>{po.date}</TableCell>
|
||||||
<TableCell>{po.po_id}</TableCell>
|
<TableCell>{po.po_id}</TableCell>
|
||||||
<TableCell>{po.ordered}</TableCell>
|
<TableCell>{po.ordered}</TableCell>
|
||||||
<TableCell>{po.received}</TableCell>
|
<TableCell>{po.received}</TableCell>
|
||||||
<TableCell>{po.status}</TableCell>
|
<TableCell>{po.status}</TableCell>
|
||||||
|
<TableCell>{po.lead_time_days ? `${po.lead_time_days} days` : 'N/A'}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
|
{(!combinedData?.recent_purchases || combinedData.recent_purchases.length === 0) && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="text-center text-muted-foreground">
|
||||||
|
No recent purchase orders
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -301,47 +484,108 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
|||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="metrics" className="space-y-4">
|
<TabsContent value="financial" className="space-y-4">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Skeleton className="h-48 w-full" />
|
<Skeleton className="h-48 w-full" />
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="space-y-4">
|
||||||
<Card className="p-4">
|
<Card className="p-4">
|
||||||
<h3 className="font-semibold mb-2">Financial Metrics</h3>
|
<h3 className="font-semibold mb-2">Financial Overview</h3>
|
||||||
<dl className="space-y-2">
|
<dl className="grid grid-cols-3 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm text-muted-foreground">Total Revenue</dt>
|
<dt className="text-sm text-muted-foreground">Gross Profit</dt>
|
||||||
<dd>${product?.total_revenue?.toFixed(2) || '0.00'}</dd>
|
<dd className="text-2xl font-semibold">${formatPrice(product?.metrics.gross_profit)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm text-muted-foreground">GMROI</dt>
|
||||||
|
<dd className="text-2xl font-semibold">{product?.metrics.gmroi.toFixed(2)}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm text-muted-foreground">Margin %</dt>
|
<dt className="text-sm text-muted-foreground">Margin %</dt>
|
||||||
<dd>{product?.avg_margin_percent?.toFixed(2) || '0.00'}%</dd>
|
<dd className="text-2xl font-semibold">{product?.metrics.avg_margin_percent.toFixed(2)}%</dd>
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt className="text-sm text-muted-foreground">Inventory Value</dt>
|
|
||||||
<dd>${product?.inventory_value?.toFixed(2) || '0.00'}</dd>
|
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="p-4">
|
<Card className="p-4">
|
||||||
<h3 className="font-semibold mb-2">Performance Metrics</h3>
|
<h3 className="font-semibold mb-2">Cost Breakdown</h3>
|
||||||
<dl className="space-y-2">
|
<dl className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm text-muted-foreground">Turnover Rate</dt>
|
<dt className="text-sm text-muted-foreground">Cost of Goods Sold</dt>
|
||||||
<dd>{product?.turnover_rate?.toFixed(2) || '0.00'}</dd>
|
<dd>${formatPrice(product?.metrics.cost_of_goods_sold)}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm text-muted-foreground">ABC Classification</dt>
|
<dt className="text-sm text-muted-foreground">Landing Cost</dt>
|
||||||
<dd>Class {product?.abc_class || 'N/A'}</dd>
|
<dd>${formatPrice(product?.landing_cost_price)}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="p-4">
|
||||||
|
<h3 className="font-semibold mb-2">Profit Margin Trend</h3>
|
||||||
|
<div className="h-64">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<LineChart data={combinedData?.monthly_sales || []}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="month" />
|
||||||
|
<YAxis domain={[0, 100]} />
|
||||||
|
<Tooltip />
|
||||||
|
<Line type="monotone" dataKey="profit_margin" stroke="#82ca9d" name="Profit Margin %" />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="vendor" className="space-y-4">
|
||||||
|
{isLoading ? (
|
||||||
|
<Skeleton className="h-48 w-full" />
|
||||||
|
) : product?.vendor_performance ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card className="p-4">
|
||||||
|
<h3 className="font-semibold mb-2">Vendor Performance</h3>
|
||||||
|
<dl className="grid grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm text-muted-foreground">On-Time Delivery</dt>
|
||||||
|
<dd className="text-2xl font-semibold">{product.vendor_performance.on_time_delivery_rate.toFixed(1)}%</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm text-muted-foreground">Stock Status</dt>
|
<dt className="text-sm text-muted-foreground">Order Fill Rate</dt>
|
||||||
<dd>{product?.stock_status || 'N/A'}</dd>
|
<dd className="text-2xl font-semibold">{product.vendor_performance.order_fill_rate.toFixed(1)}%</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm text-muted-foreground">Avg Lead Time</dt>
|
||||||
|
<dd className="text-2xl font-semibold">{product.vendor_performance.avg_lead_time_days} days</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="p-4">
|
||||||
|
<h3 className="font-semibold mb-2">Order History</h3>
|
||||||
|
<dl className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm text-muted-foreground">Total Orders</dt>
|
||||||
|
<dd>{product.vendor_performance.total_orders}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm text-muted-foreground">Late Orders</dt>
|
||||||
|
<dd>{product.vendor_performance.total_late_orders}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm text-muted-foreground">Total Purchase Value</dt>
|
||||||
|
<dd>${formatPrice(product.vendor_performance.total_purchase_value)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm text-muted-foreground">Avg Order Value</dt>
|
||||||
|
<dd>${formatPrice(product.vendor_performance.avg_order_value)}</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center text-muted-foreground">No vendor performance data available</div>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|||||||
Reference in New Issue
Block a user