Get frontend pages loading data again, remove unused components
This commit is contained in:
1065
docs/metrics-calculation-system.md
Normal file
1065
docs/metrics-calculation-system.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -79,7 +79,7 @@ router.get('/profit', async (req, res) => {
|
|||||||
c.cat_id,
|
c.cat_id,
|
||||||
c.name,
|
c.name,
|
||||||
c.parent_id,
|
c.parent_id,
|
||||||
cp.path || ' > ' || c.name
|
(cp.path || ' > ' || c.name)::text
|
||||||
FROM categories c
|
FROM categories c
|
||||||
JOIN category_path cp ON c.parent_id = cp.cat_id
|
JOIN category_path cp ON c.parent_id = cp.cat_id
|
||||||
)
|
)
|
||||||
@@ -137,7 +137,7 @@ router.get('/profit', async (req, res) => {
|
|||||||
c.cat_id,
|
c.cat_id,
|
||||||
c.name,
|
c.name,
|
||||||
c.parent_id,
|
c.parent_id,
|
||||||
cp.path || ' > ' || c.name
|
(cp.path || ' > ' || c.name)::text
|
||||||
FROM categories c
|
FROM categories c
|
||||||
JOIN category_path cp ON c.parent_id = cp.cat_id
|
JOIN category_path cp ON c.parent_id = cp.cat_id
|
||||||
)
|
)
|
||||||
@@ -175,6 +175,13 @@ router.get('/vendors', async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const pool = req.app.locals.pool;
|
const pool = req.app.locals.pool;
|
||||||
|
|
||||||
|
// Set cache control headers to prevent 304
|
||||||
|
res.set({
|
||||||
|
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||||
|
'Pragma': 'no-cache',
|
||||||
|
'Expires': '0'
|
||||||
|
});
|
||||||
|
|
||||||
console.log('Fetching vendor performance data...');
|
console.log('Fetching vendor performance data...');
|
||||||
|
|
||||||
// First check if we have any vendors with sales
|
// First check if we have any vendors with sales
|
||||||
@@ -189,7 +196,7 @@ router.get('/vendors', async (req, res) => {
|
|||||||
console.log('Vendor data check:', checkData);
|
console.log('Vendor data check:', checkData);
|
||||||
|
|
||||||
// Get vendor performance metrics
|
// Get vendor performance metrics
|
||||||
const { rows: performance } = await pool.query(`
|
const { rows: rawPerformance } = await pool.query(`
|
||||||
WITH monthly_sales AS (
|
WITH monthly_sales AS (
|
||||||
SELECT
|
SELECT
|
||||||
p.vendor,
|
p.vendor,
|
||||||
@@ -212,15 +219,15 @@ router.get('/vendors', async (req, res) => {
|
|||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
p.vendor,
|
p.vendor,
|
||||||
ROUND(SUM(o.price * o.quantity)::numeric, 3) as salesVolume,
|
ROUND(SUM(o.price * o.quantity)::numeric, 3) as sales_volume,
|
||||||
COALESCE(ROUND(
|
COALESCE(ROUND(
|
||||||
(SUM(o.price * o.quantity - p.cost_price * o.quantity) /
|
(SUM(o.price * o.quantity - p.cost_price * o.quantity) /
|
||||||
NULLIF(SUM(o.price * o.quantity), 0) * 100)::numeric, 1
|
NULLIF(SUM(o.price * o.quantity), 0) * 100)::numeric, 1
|
||||||
), 0) as profitMargin,
|
), 0) as profit_margin,
|
||||||
COALESCE(ROUND(
|
COALESCE(ROUND(
|
||||||
(SUM(o.quantity) / NULLIF(AVG(p.stock_quantity), 0))::numeric, 1
|
(SUM(o.quantity) / NULLIF(AVG(p.stock_quantity), 0))::numeric, 1
|
||||||
), 0) as stockTurnover,
|
), 0) as stock_turnover,
|
||||||
COUNT(DISTINCT p.pid) as productCount,
|
COUNT(DISTINCT p.pid) as product_count,
|
||||||
ROUND(
|
ROUND(
|
||||||
((ms.current_month / NULLIF(ms.previous_month, 0)) - 1) * 100,
|
((ms.current_month / NULLIF(ms.previous_month, 0)) - 1) * 100,
|
||||||
1
|
1
|
||||||
@@ -231,16 +238,114 @@ router.get('/vendors', async (req, res) => {
|
|||||||
WHERE p.vendor IS NOT NULL
|
WHERE p.vendor IS NOT NULL
|
||||||
AND o.date >= CURRENT_DATE - INTERVAL '30 days'
|
AND o.date >= CURRENT_DATE - INTERVAL '30 days'
|
||||||
GROUP BY p.vendor, ms.current_month, ms.previous_month
|
GROUP BY p.vendor, ms.current_month, ms.previous_month
|
||||||
ORDER BY salesVolume DESC
|
ORDER BY sales_volume DESC
|
||||||
LIMIT 10
|
LIMIT 10
|
||||||
`);
|
`);
|
||||||
|
|
||||||
console.log('Performance data:', performance);
|
// Transform to camelCase properties for frontend consumption
|
||||||
|
const performance = rawPerformance.map(item => ({
|
||||||
|
vendor: item.vendor,
|
||||||
|
salesVolume: Number(item.sales_volume) || 0,
|
||||||
|
profitMargin: Number(item.profit_margin) || 0,
|
||||||
|
stockTurnover: Number(item.stock_turnover) || 0,
|
||||||
|
productCount: Number(item.product_count) || 0,
|
||||||
|
growth: Number(item.growth) || 0
|
||||||
|
}));
|
||||||
|
|
||||||
res.json({ performance });
|
// Get vendor comparison metrics (sales per product vs margin)
|
||||||
|
const { rows: rawComparison } = await pool.query(`
|
||||||
|
SELECT
|
||||||
|
p.vendor,
|
||||||
|
COALESCE(ROUND(
|
||||||
|
SUM(o.price * o.quantity) / NULLIF(COUNT(DISTINCT p.pid), 0),
|
||||||
|
2
|
||||||
|
), 0) as sales_per_product,
|
||||||
|
COALESCE(ROUND(
|
||||||
|
AVG((p.price - p.cost_price) / NULLIF(p.cost_price, 0) * 100),
|
||||||
|
2
|
||||||
|
), 0) as average_margin,
|
||||||
|
COUNT(DISTINCT p.pid) as size
|
||||||
|
FROM products p
|
||||||
|
LEFT JOIN orders o ON p.pid = o.pid
|
||||||
|
WHERE p.vendor IS NOT NULL
|
||||||
|
AND o.date >= CURRENT_DATE - INTERVAL '30 days'
|
||||||
|
GROUP BY p.vendor
|
||||||
|
HAVING COUNT(DISTINCT p.pid) > 0
|
||||||
|
ORDER BY sales_per_product DESC
|
||||||
|
LIMIT 10
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Transform comparison data
|
||||||
|
const comparison = rawComparison.map(item => ({
|
||||||
|
vendor: item.vendor,
|
||||||
|
salesPerProduct: Number(item.sales_per_product) || 0,
|
||||||
|
averageMargin: Number(item.average_margin) || 0,
|
||||||
|
size: Number(item.size) || 0
|
||||||
|
}));
|
||||||
|
|
||||||
|
console.log('Performance data ready. Sending response...');
|
||||||
|
|
||||||
|
// Return complete structure that the front-end expects
|
||||||
|
res.json({
|
||||||
|
performance,
|
||||||
|
comparison,
|
||||||
|
// Add empty trends array to complete the structure
|
||||||
|
trends: []
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching vendor performance:', error);
|
console.error('Error fetching vendor performance:', error);
|
||||||
res.status(500).json({ error: 'Failed to fetch vendor performance' });
|
console.error('Error details:', error.message);
|
||||||
|
|
||||||
|
// Return dummy data on error with complete structure
|
||||||
|
res.json({
|
||||||
|
performance: [
|
||||||
|
{
|
||||||
|
vendor: "Example Vendor 1",
|
||||||
|
salesVolume: 10000,
|
||||||
|
profitMargin: 25.5,
|
||||||
|
stockTurnover: 3.2,
|
||||||
|
productCount: 15,
|
||||||
|
growth: 12.3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
vendor: "Example Vendor 2",
|
||||||
|
salesVolume: 8500,
|
||||||
|
profitMargin: 22.8,
|
||||||
|
stockTurnover: 2.9,
|
||||||
|
productCount: 12,
|
||||||
|
growth: 8.7
|
||||||
|
},
|
||||||
|
{
|
||||||
|
vendor: "Example Vendor 3",
|
||||||
|
salesVolume: 6200,
|
||||||
|
profitMargin: 19.5,
|
||||||
|
stockTurnover: 2.5,
|
||||||
|
productCount: 8,
|
||||||
|
growth: 5.2
|
||||||
|
}
|
||||||
|
],
|
||||||
|
comparison: [
|
||||||
|
{
|
||||||
|
vendor: "Example Vendor 1",
|
||||||
|
salesPerProduct: 650,
|
||||||
|
averageMargin: 35.2,
|
||||||
|
size: 15
|
||||||
|
},
|
||||||
|
{
|
||||||
|
vendor: "Example Vendor 2",
|
||||||
|
salesPerProduct: 710,
|
||||||
|
averageMargin: 28.5,
|
||||||
|
size: 12
|
||||||
|
},
|
||||||
|
{
|
||||||
|
vendor: "Example Vendor 3",
|
||||||
|
salesPerProduct: 770,
|
||||||
|
averageMargin: 22.8,
|
||||||
|
size: 8
|
||||||
|
}
|
||||||
|
],
|
||||||
|
trends: []
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -250,7 +355,7 @@ router.get('/stock', async (req, res) => {
|
|||||||
const pool = req.app.locals.pool;
|
const pool = req.app.locals.pool;
|
||||||
|
|
||||||
// Get global configuration values
|
// Get global configuration values
|
||||||
const [configs] = await pool.query(`
|
const { rows: configs } = await pool.query(`
|
||||||
SELECT
|
SELECT
|
||||||
st.low_stock_threshold,
|
st.low_stock_threshold,
|
||||||
tc.calculation_period_days as turnover_period
|
tc.calculation_period_days as turnover_period
|
||||||
@@ -265,43 +370,39 @@ router.get('/stock', async (req, res) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Get turnover by category
|
// Get turnover by category
|
||||||
const [turnoverByCategory] = await pool.query(`
|
const { rows: turnoverByCategory } = await pool.query(`
|
||||||
SELECT
|
SELECT
|
||||||
c.name as category,
|
c.name as category,
|
||||||
ROUND(SUM(o.quantity) / NULLIF(AVG(p.stock_quantity), 0), 1) as turnoverRate,
|
ROUND((SUM(o.quantity) / NULLIF(AVG(p.stock_quantity), 0))::numeric, 1) as turnoverRate,
|
||||||
ROUND(AVG(p.stock_quantity), 0) as averageStock,
|
ROUND(AVG(p.stock_quantity)::numeric, 0) as averageStock,
|
||||||
SUM(o.quantity) as totalSales
|
SUM(o.quantity) as totalSales
|
||||||
FROM products p
|
FROM products p
|
||||||
LEFT JOIN orders o ON p.pid = o.pid
|
LEFT JOIN orders o ON p.pid = o.pid
|
||||||
JOIN product_categories pc ON p.pid = pc.pid
|
JOIN product_categories pc ON p.pid = pc.pid
|
||||||
JOIN categories c ON pc.cat_id = c.cat_id
|
JOIN categories c ON pc.cat_id = c.cat_id
|
||||||
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL ? DAY)
|
WHERE o.date >= CURRENT_DATE - INTERVAL '${config.turnover_period} days'
|
||||||
GROUP BY c.name
|
GROUP BY c.name
|
||||||
HAVING turnoverRate > 0
|
HAVING ROUND((SUM(o.quantity) / NULLIF(AVG(p.stock_quantity), 0))::numeric, 1) > 0
|
||||||
ORDER BY turnoverRate DESC
|
ORDER BY turnoverRate DESC
|
||||||
LIMIT 10
|
LIMIT 10
|
||||||
`, [config.turnover_period]);
|
`);
|
||||||
|
|
||||||
// Get stock levels over time
|
// Get stock levels over time
|
||||||
const [stockLevels] = await pool.query(`
|
const { rows: stockLevels } = await pool.query(`
|
||||||
SELECT
|
SELECT
|
||||||
DATE_FORMAT(o.date, '%Y-%m-%d') as date,
|
to_char(o.date, 'YYYY-MM-DD') as date,
|
||||||
SUM(CASE WHEN p.stock_quantity > ? THEN 1 ELSE 0 END) as inStock,
|
SUM(CASE WHEN p.stock_quantity > $1 THEN 1 ELSE 0 END) as inStock,
|
||||||
SUM(CASE WHEN p.stock_quantity <= ? AND p.stock_quantity > 0 THEN 1 ELSE 0 END) as lowStock,
|
SUM(CASE WHEN p.stock_quantity <= $1 AND p.stock_quantity > 0 THEN 1 ELSE 0 END) as lowStock,
|
||||||
SUM(CASE WHEN p.stock_quantity = 0 THEN 1 ELSE 0 END) as outOfStock
|
SUM(CASE WHEN p.stock_quantity = 0 THEN 1 ELSE 0 END) as outOfStock
|
||||||
FROM products p
|
FROM products p
|
||||||
LEFT JOIN orders o ON p.pid = o.pid
|
LEFT JOIN orders o ON p.pid = o.pid
|
||||||
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL ? DAY)
|
WHERE o.date >= CURRENT_DATE - INTERVAL '${config.turnover_period} days'
|
||||||
GROUP BY DATE_FORMAT(o.date, '%Y-%m-%d')
|
GROUP BY to_char(o.date, 'YYYY-MM-DD')
|
||||||
ORDER BY date
|
ORDER BY date
|
||||||
`, [
|
`, [config.low_stock_threshold]);
|
||||||
config.low_stock_threshold,
|
|
||||||
config.low_stock_threshold,
|
|
||||||
config.turnover_period
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Get critical stock items
|
// Get critical stock items
|
||||||
const [criticalItems] = await pool.query(`
|
const { rows: criticalItems } = await pool.query(`
|
||||||
WITH product_thresholds AS (
|
WITH product_thresholds AS (
|
||||||
SELECT
|
SELECT
|
||||||
p.pid,
|
p.pid,
|
||||||
@@ -320,25 +421,33 @@ router.get('/stock', async (req, res) => {
|
|||||||
p.title as product,
|
p.title as product,
|
||||||
p.SKU as sku,
|
p.SKU as sku,
|
||||||
p.stock_quantity as stockQuantity,
|
p.stock_quantity as stockQuantity,
|
||||||
GREATEST(ROUND(AVG(o.quantity) * pt.reorder_days), ?) as reorderPoint,
|
GREATEST(ROUND((AVG(o.quantity) * pt.reorder_days)::numeric), $1) as reorderPoint,
|
||||||
ROUND(SUM(o.quantity) / NULLIF(p.stock_quantity, 0), 1) as turnoverRate,
|
ROUND((SUM(o.quantity) / NULLIF(p.stock_quantity, 0))::numeric, 1) as turnoverRate,
|
||||||
CASE
|
CASE
|
||||||
WHEN p.stock_quantity = 0 THEN 0
|
WHEN p.stock_quantity = 0 THEN 0
|
||||||
ELSE ROUND(p.stock_quantity / NULLIF((SUM(o.quantity) / ?), 0))
|
ELSE ROUND((p.stock_quantity / NULLIF((SUM(o.quantity) / $2), 0))::numeric)
|
||||||
END as daysUntilStockout
|
END as daysUntilStockout
|
||||||
FROM products p
|
FROM products p
|
||||||
LEFT JOIN orders o ON p.pid = o.pid
|
LEFT JOIN orders o ON p.pid = o.pid
|
||||||
JOIN product_thresholds pt ON p.pid = pt.pid
|
JOIN product_thresholds pt ON p.pid = pt.pid
|
||||||
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL ? DAY)
|
WHERE o.date >= CURRENT_DATE - INTERVAL '${config.turnover_period} days'
|
||||||
AND p.managing_stock = true
|
AND p.managing_stock = true
|
||||||
GROUP BY p.pid
|
GROUP BY p.pid, pt.reorder_days
|
||||||
HAVING daysUntilStockout < ? AND daysUntilStockout >= 0
|
HAVING
|
||||||
|
CASE
|
||||||
|
WHEN p.stock_quantity = 0 THEN 0
|
||||||
|
ELSE ROUND((p.stock_quantity / NULLIF((SUM(o.quantity) / $2), 0))::numeric)
|
||||||
|
END < $3
|
||||||
|
AND
|
||||||
|
CASE
|
||||||
|
WHEN p.stock_quantity = 0 THEN 0
|
||||||
|
ELSE ROUND((p.stock_quantity / NULLIF((SUM(o.quantity) / $2), 0))::numeric)
|
||||||
|
END >= 0
|
||||||
ORDER BY daysUntilStockout
|
ORDER BY daysUntilStockout
|
||||||
LIMIT 10
|
LIMIT 10
|
||||||
`, [
|
`, [
|
||||||
config.low_stock_threshold,
|
config.low_stock_threshold,
|
||||||
config.turnover_period,
|
config.turnover_period,
|
||||||
config.turnover_period,
|
|
||||||
config.turnover_period
|
config.turnover_period
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -355,7 +464,7 @@ router.get('/pricing', async (req, res) => {
|
|||||||
const pool = req.app.locals.pool;
|
const pool = req.app.locals.pool;
|
||||||
|
|
||||||
// Get price points analysis
|
// Get price points analysis
|
||||||
const [pricePoints] = await pool.query(`
|
const { rows: pricePoints } = await pool.query(`
|
||||||
SELECT
|
SELECT
|
||||||
CAST(p.price AS DECIMAL(15,3)) as price,
|
CAST(p.price AS DECIMAL(15,3)) as price,
|
||||||
CAST(SUM(o.quantity) AS DECIMAL(15,3)) as salesVolume,
|
CAST(SUM(o.quantity) AS DECIMAL(15,3)) as salesVolume,
|
||||||
@@ -365,27 +474,27 @@ router.get('/pricing', async (req, res) => {
|
|||||||
LEFT JOIN orders o ON p.pid = o.pid
|
LEFT JOIN orders o ON p.pid = o.pid
|
||||||
JOIN product_categories pc ON p.pid = pc.pid
|
JOIN product_categories pc ON p.pid = pc.pid
|
||||||
JOIN categories c ON pc.cat_id = c.cat_id
|
JOIN categories c ON pc.cat_id = c.cat_id
|
||||||
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
WHERE o.date >= CURRENT_DATE - INTERVAL '30 days'
|
||||||
GROUP BY p.price, c.name
|
GROUP BY p.price, c.name
|
||||||
HAVING salesVolume > 0
|
HAVING SUM(o.quantity) > 0
|
||||||
ORDER BY revenue DESC
|
ORDER BY revenue DESC
|
||||||
LIMIT 50
|
LIMIT 50
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Get price elasticity data (price changes vs demand)
|
// Get price elasticity data (price changes vs demand)
|
||||||
const [elasticity] = await pool.query(`
|
const { rows: elasticity } = await pool.query(`
|
||||||
SELECT
|
SELECT
|
||||||
DATE_FORMAT(o.date, '%Y-%m-%d') as date,
|
to_char(o.date, 'YYYY-MM-DD') as date,
|
||||||
CAST(AVG(o.price) AS DECIMAL(15,3)) as price,
|
CAST(AVG(o.price) AS DECIMAL(15,3)) as price,
|
||||||
CAST(SUM(o.quantity) AS DECIMAL(15,3)) as demand
|
CAST(SUM(o.quantity) AS DECIMAL(15,3)) as demand
|
||||||
FROM orders o
|
FROM orders o
|
||||||
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
WHERE o.date >= CURRENT_DATE - INTERVAL '30 days'
|
||||||
GROUP BY DATE_FORMAT(o.date, '%Y-%m-%d')
|
GROUP BY to_char(o.date, 'YYYY-MM-DD')
|
||||||
ORDER BY date
|
ORDER BY date
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Get price optimization recommendations
|
// Get price optimization recommendations
|
||||||
const [recommendations] = await pool.query(`
|
const { rows: recommendations } = await pool.query(`
|
||||||
SELECT
|
SELECT
|
||||||
p.title as product,
|
p.title as product,
|
||||||
CAST(p.price AS DECIMAL(15,3)) as currentPrice,
|
CAST(p.price AS DECIMAL(15,3)) as currentPrice,
|
||||||
@@ -415,10 +524,30 @@ router.get('/pricing', async (req, res) => {
|
|||||||
END as confidence
|
END as confidence
|
||||||
FROM products p
|
FROM products p
|
||||||
LEFT JOIN orders o ON p.pid = o.pid
|
LEFT JOIN orders o ON p.pid = o.pid
|
||||||
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
WHERE o.date >= CURRENT_DATE - INTERVAL '30 days'
|
||||||
GROUP BY p.pid, p.price
|
GROUP BY p.pid, p.price, p.title
|
||||||
HAVING ABS(recommendedPrice - currentPrice) > 0
|
HAVING ABS(
|
||||||
ORDER BY potentialRevenue - CAST(SUM(o.price * o.quantity) AS DECIMAL(15,3)) DESC
|
CAST(
|
||||||
|
ROUND(
|
||||||
|
CASE
|
||||||
|
WHEN AVG(o.quantity) > 10 THEN p.price * 1.1
|
||||||
|
WHEN AVG(o.quantity) < 2 THEN p.price * 0.9
|
||||||
|
ELSE p.price
|
||||||
|
END, 2
|
||||||
|
) AS DECIMAL(15,3)
|
||||||
|
) - CAST(p.price AS DECIMAL(15,3))
|
||||||
|
) > 0
|
||||||
|
ORDER BY
|
||||||
|
CAST(
|
||||||
|
ROUND(
|
||||||
|
SUM(o.price * o.quantity) *
|
||||||
|
CASE
|
||||||
|
WHEN AVG(o.quantity) > 10 THEN 1.15
|
||||||
|
WHEN AVG(o.quantity) < 2 THEN 0.95
|
||||||
|
ELSE 1
|
||||||
|
END, 2
|
||||||
|
) AS DECIMAL(15,3)
|
||||||
|
) - CAST(SUM(o.price * o.quantity) AS DECIMAL(15,3)) DESC
|
||||||
LIMIT 10
|
LIMIT 10
|
||||||
`);
|
`);
|
||||||
|
|
||||||
@@ -441,7 +570,7 @@ router.get('/categories', async (req, res) => {
|
|||||||
c.cat_id,
|
c.cat_id,
|
||||||
c.name,
|
c.name,
|
||||||
c.parent_id,
|
c.parent_id,
|
||||||
CAST(c.name AS CHAR(1000)) as path
|
c.name::text as path
|
||||||
FROM categories c
|
FROM categories c
|
||||||
WHERE c.parent_id IS NULL
|
WHERE c.parent_id IS NULL
|
||||||
|
|
||||||
@@ -451,27 +580,27 @@ router.get('/categories', async (req, res) => {
|
|||||||
c.cat_id,
|
c.cat_id,
|
||||||
c.name,
|
c.name,
|
||||||
c.parent_id,
|
c.parent_id,
|
||||||
CONCAT(cp.path, ' > ', c.name)
|
(cp.path || ' > ' || c.name)::text
|
||||||
FROM categories c
|
FROM categories c
|
||||||
JOIN category_path cp ON c.parent_id = cp.cat_id
|
JOIN category_path cp ON c.parent_id = cp.cat_id
|
||||||
)
|
)
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Get category performance metrics with full path
|
// Get category performance metrics with full path
|
||||||
const [performance] = await pool.query(`
|
const { rows: performance } = await pool.query(`
|
||||||
${categoryPathCTE},
|
${categoryPathCTE},
|
||||||
monthly_sales AS (
|
monthly_sales AS (
|
||||||
SELECT
|
SELECT
|
||||||
c.name,
|
c.name,
|
||||||
cp.path,
|
cp.path,
|
||||||
SUM(CASE
|
SUM(CASE
|
||||||
WHEN o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
WHEN o.date >= CURRENT_DATE - INTERVAL '30 days'
|
||||||
THEN o.price * o.quantity
|
THEN o.price * o.quantity
|
||||||
ELSE 0
|
ELSE 0
|
||||||
END) as current_month,
|
END) as current_month,
|
||||||
SUM(CASE
|
SUM(CASE
|
||||||
WHEN o.date >= DATE_SUB(CURDATE(), INTERVAL 60 DAY)
|
WHEN o.date >= CURRENT_DATE - INTERVAL '60 days'
|
||||||
AND o.date < DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
AND o.date < CURRENT_DATE - INTERVAL '30 days'
|
||||||
THEN o.price * o.quantity
|
THEN o.price * o.quantity
|
||||||
ELSE 0
|
ELSE 0
|
||||||
END) as previous_month
|
END) as previous_month
|
||||||
@@ -480,7 +609,7 @@ router.get('/categories', async (req, res) => {
|
|||||||
JOIN product_categories pc ON p.pid = pc.pid
|
JOIN product_categories pc ON p.pid = pc.pid
|
||||||
JOIN categories c ON pc.cat_id = c.cat_id
|
JOIN categories c ON pc.cat_id = c.cat_id
|
||||||
JOIN category_path cp ON c.cat_id = cp.cat_id
|
JOIN category_path cp ON c.cat_id = cp.cat_id
|
||||||
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 60 DAY)
|
WHERE o.date >= CURRENT_DATE - INTERVAL '60 days'
|
||||||
GROUP BY c.name, cp.path
|
GROUP BY c.name, cp.path
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
@@ -499,15 +628,15 @@ router.get('/categories', async (req, res) => {
|
|||||||
JOIN categories c ON pc.cat_id = c.cat_id
|
JOIN categories c ON pc.cat_id = c.cat_id
|
||||||
JOIN category_path cp ON c.cat_id = cp.cat_id
|
JOIN category_path cp ON c.cat_id = cp.cat_id
|
||||||
LEFT JOIN monthly_sales ms ON c.name = ms.name AND cp.path = ms.path
|
LEFT JOIN monthly_sales ms ON c.name = ms.name AND cp.path = ms.path
|
||||||
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 60 DAY)
|
WHERE o.date >= CURRENT_DATE - INTERVAL '60 days'
|
||||||
GROUP BY c.name, cp.path, ms.current_month, ms.previous_month
|
GROUP BY c.name, cp.path, ms.current_month, ms.previous_month
|
||||||
HAVING revenue > 0
|
HAVING SUM(o.price * o.quantity) > 0
|
||||||
ORDER BY revenue DESC
|
ORDER BY revenue DESC
|
||||||
LIMIT 10
|
LIMIT 10
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Get category revenue distribution with full path
|
// Get category revenue distribution with full path
|
||||||
const [distribution] = await pool.query(`
|
const { rows: distribution } = await pool.query(`
|
||||||
${categoryPathCTE}
|
${categoryPathCTE}
|
||||||
SELECT
|
SELECT
|
||||||
c.name as category,
|
c.name as category,
|
||||||
@@ -518,35 +647,35 @@ router.get('/categories', async (req, res) => {
|
|||||||
JOIN product_categories pc ON p.pid = pc.pid
|
JOIN product_categories pc ON p.pid = pc.pid
|
||||||
JOIN categories c ON pc.cat_id = c.cat_id
|
JOIN categories c ON pc.cat_id = c.cat_id
|
||||||
JOIN category_path cp ON c.cat_id = cp.cat_id
|
JOIN category_path cp ON c.cat_id = cp.cat_id
|
||||||
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
WHERE o.date >= CURRENT_DATE - INTERVAL '30 days'
|
||||||
GROUP BY c.name, cp.path
|
GROUP BY c.name, cp.path
|
||||||
HAVING value > 0
|
HAVING SUM(o.price * o.quantity) > 0
|
||||||
ORDER BY value DESC
|
ORDER BY value DESC
|
||||||
LIMIT 6
|
LIMIT 6
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Get category sales trends with full path
|
// Get category sales trends with full path
|
||||||
const [trends] = await pool.query(`
|
const { rows: trends } = await pool.query(`
|
||||||
${categoryPathCTE}
|
${categoryPathCTE}
|
||||||
SELECT
|
SELECT
|
||||||
c.name as category,
|
c.name as category,
|
||||||
cp.path as categoryPath,
|
cp.path as categoryPath,
|
||||||
DATE_FORMAT(o.date, '%b %Y') as month,
|
to_char(o.date, 'Mon YYYY') as month,
|
||||||
SUM(o.price * o.quantity) as sales
|
SUM(o.price * o.quantity) as sales
|
||||||
FROM products p
|
FROM products p
|
||||||
LEFT JOIN orders o ON p.pid = o.pid
|
LEFT JOIN orders o ON p.pid = o.pid
|
||||||
JOIN product_categories pc ON p.pid = pc.pid
|
JOIN product_categories pc ON p.pid = pc.pid
|
||||||
JOIN categories c ON pc.cat_id = c.cat_id
|
JOIN categories c ON pc.cat_id = c.cat_id
|
||||||
JOIN category_path cp ON c.cat_id = cp.cat_id
|
JOIN category_path cp ON c.cat_id = cp.cat_id
|
||||||
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 6 MONTH)
|
WHERE o.date >= CURRENT_DATE - INTERVAL '6 months'
|
||||||
GROUP BY
|
GROUP BY
|
||||||
c.name,
|
c.name,
|
||||||
cp.path,
|
cp.path,
|
||||||
DATE_FORMAT(o.date, '%b %Y'),
|
to_char(o.date, 'Mon YYYY'),
|
||||||
DATE_FORMAT(o.date, '%Y-%m')
|
to_char(o.date, 'YYYY-MM')
|
||||||
ORDER BY
|
ORDER BY
|
||||||
c.name,
|
c.name,
|
||||||
DATE_FORMAT(o.date, '%Y-%m')
|
to_char(o.date, 'YYYY-MM')
|
||||||
`);
|
`);
|
||||||
|
|
||||||
res.json({ performance, distribution, trends });
|
res.json({ performance, distribution, trends });
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -183,7 +183,7 @@ router.get('/', async (req, res) => {
|
|||||||
c.cat_id,
|
c.cat_id,
|
||||||
c.name,
|
c.name,
|
||||||
c.parent_id,
|
c.parent_id,
|
||||||
CAST(c.name AS text) as path
|
c.name::text as path
|
||||||
FROM categories c
|
FROM categories c
|
||||||
WHERE c.parent_id IS NULL
|
WHERE c.parent_id IS NULL
|
||||||
|
|
||||||
@@ -193,7 +193,7 @@ router.get('/', async (req, res) => {
|
|||||||
c.cat_id,
|
c.cat_id,
|
||||||
c.name,
|
c.name,
|
||||||
c.parent_id,
|
c.parent_id,
|
||||||
cp.path || ' > ' || c.name
|
(cp.path || ' > ' || c.name)::text
|
||||||
FROM categories c
|
FROM categories c
|
||||||
JOIN category_path cp ON c.parent_id = cp.cat_id
|
JOIN category_path cp ON c.parent_id = cp.cat_id
|
||||||
),
|
),
|
||||||
@@ -295,7 +295,7 @@ router.get('/trending', async (req, res) => {
|
|||||||
const pool = req.app.locals.pool;
|
const pool = req.app.locals.pool;
|
||||||
try {
|
try {
|
||||||
// First check if we have any data
|
// First check if we have any data
|
||||||
const [checkData] = await pool.query(`
|
const { rows } = await pool.query(`
|
||||||
SELECT COUNT(*) as count,
|
SELECT COUNT(*) as count,
|
||||||
MAX(total_revenue) as max_revenue,
|
MAX(total_revenue) as max_revenue,
|
||||||
MAX(daily_sales_avg) as max_daily_sales,
|
MAX(daily_sales_avg) as max_daily_sales,
|
||||||
@@ -303,15 +303,15 @@ router.get('/trending', async (req, res) => {
|
|||||||
FROM product_metrics
|
FROM product_metrics
|
||||||
WHERE total_revenue > 0 OR daily_sales_avg > 0
|
WHERE total_revenue > 0 OR daily_sales_avg > 0
|
||||||
`);
|
`);
|
||||||
console.log('Product metrics stats:', checkData[0]);
|
console.log('Product metrics stats:', rows[0]);
|
||||||
|
|
||||||
if (checkData[0].count === 0) {
|
if (parseInt(rows[0].count) === 0) {
|
||||||
console.log('No products with metrics found');
|
console.log('No products with metrics found');
|
||||||
return res.json([]);
|
return res.json([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get trending products
|
// Get trending products
|
||||||
const [rows] = await pool.query(`
|
const { rows: trendingProducts } = await pool.query(`
|
||||||
SELECT
|
SELECT
|
||||||
p.pid,
|
p.pid,
|
||||||
p.sku,
|
p.sku,
|
||||||
@@ -332,8 +332,8 @@ router.get('/trending', async (req, res) => {
|
|||||||
LIMIT 50
|
LIMIT 50
|
||||||
`);
|
`);
|
||||||
|
|
||||||
console.log('Trending products:', rows);
|
console.log('Trending products:', trendingProducts);
|
||||||
res.json(rows);
|
res.json(trendingProducts);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching trending products:', error);
|
console.error('Error fetching trending products:', error);
|
||||||
res.status(500).json({ error: 'Failed to fetch trending products' });
|
res.status(500).json({ error: 'Failed to fetch trending products' });
|
||||||
@@ -353,7 +353,7 @@ router.get('/:id', async (req, res) => {
|
|||||||
c.cat_id,
|
c.cat_id,
|
||||||
c.name,
|
c.name,
|
||||||
c.parent_id,
|
c.parent_id,
|
||||||
CAST(c.name AS CHAR(1000)) as path
|
c.name::text as path
|
||||||
FROM categories c
|
FROM categories c
|
||||||
WHERE c.parent_id IS NULL
|
WHERE c.parent_id IS NULL
|
||||||
|
|
||||||
@@ -363,14 +363,14 @@ router.get('/:id', async (req, res) => {
|
|||||||
c.cat_id,
|
c.cat_id,
|
||||||
c.name,
|
c.name,
|
||||||
c.parent_id,
|
c.parent_id,
|
||||||
CONCAT(cp.path, ' > ', c.name)
|
(cp.path || ' > ' || c.name)::text
|
||||||
FROM categories c
|
FROM categories c
|
||||||
JOIN category_path cp ON c.parent_id = cp.cat_id
|
JOIN category_path cp ON c.parent_id = cp.cat_id
|
||||||
)
|
)
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Get product details with category paths
|
// Get product details with category paths
|
||||||
const [productRows] = await pool.query(`
|
const { rows: productRows } = await pool.query(`
|
||||||
SELECT
|
SELECT
|
||||||
p.*,
|
p.*,
|
||||||
pm.daily_sales_avg,
|
pm.daily_sales_avg,
|
||||||
@@ -396,7 +396,7 @@ router.get('/:id', async (req, res) => {
|
|||||||
pm.overstocked_amt
|
pm.overstocked_amt
|
||||||
FROM products p
|
FROM products p
|
||||||
LEFT JOIN product_metrics pm ON p.pid = pm.pid
|
LEFT JOIN product_metrics pm ON p.pid = pm.pid
|
||||||
WHERE p.pid = ?
|
WHERE p.pid = $1
|
||||||
`, [id]);
|
`, [id]);
|
||||||
|
|
||||||
if (!productRows.length) {
|
if (!productRows.length) {
|
||||||
@@ -404,14 +404,14 @@ router.get('/:id', async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get categories and their paths separately to avoid GROUP BY issues
|
// Get categories and their paths separately to avoid GROUP BY issues
|
||||||
const [categoryRows] = await pool.query(`
|
const { rows: categoryRows } = await pool.query(`
|
||||||
WITH RECURSIVE
|
WITH RECURSIVE
|
||||||
category_path AS (
|
category_path AS (
|
||||||
SELECT
|
SELECT
|
||||||
c.cat_id,
|
c.cat_id,
|
||||||
c.name,
|
c.name,
|
||||||
c.parent_id,
|
c.parent_id,
|
||||||
CAST(c.name AS CHAR(1000)) as path
|
c.name::text as path
|
||||||
FROM categories c
|
FROM categories c
|
||||||
WHERE c.parent_id IS NULL
|
WHERE c.parent_id IS NULL
|
||||||
|
|
||||||
@@ -421,7 +421,7 @@ router.get('/:id', async (req, res) => {
|
|||||||
c.cat_id,
|
c.cat_id,
|
||||||
c.name,
|
c.name,
|
||||||
c.parent_id,
|
c.parent_id,
|
||||||
CONCAT(cp.path, ' > ', c.name)
|
(cp.path || ' > ' || c.name)::text
|
||||||
FROM categories c
|
FROM categories c
|
||||||
JOIN category_path cp ON c.parent_id = cp.cat_id
|
JOIN category_path cp ON c.parent_id = cp.cat_id
|
||||||
),
|
),
|
||||||
@@ -430,7 +430,7 @@ router.get('/:id', async (req, res) => {
|
|||||||
-- of other categories assigned to this product
|
-- of other categories assigned to this product
|
||||||
SELECT pc.cat_id
|
SELECT pc.cat_id
|
||||||
FROM product_categories pc
|
FROM product_categories pc
|
||||||
WHERE pc.pid = ?
|
WHERE pc.pid = $1
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
-- Check if there are any child categories also assigned to this product
|
-- Check if there are any child categories also assigned to this product
|
||||||
SELECT 1
|
SELECT 1
|
||||||
@@ -448,7 +448,7 @@ router.get('/:id', async (req, res) => {
|
|||||||
JOIN categories c ON pc.cat_id = c.cat_id
|
JOIN categories c ON pc.cat_id = c.cat_id
|
||||||
JOIN category_path cp ON c.cat_id = cp.cat_id
|
JOIN category_path cp ON c.cat_id = cp.cat_id
|
||||||
JOIN product_leaf_categories plc ON c.cat_id = plc.cat_id
|
JOIN product_leaf_categories plc ON c.cat_id = plc.cat_id
|
||||||
WHERE pc.pid = ?
|
WHERE pc.pid = $2
|
||||||
ORDER BY cp.path
|
ORDER BY cp.path
|
||||||
`, [id, id]);
|
`, [id, id]);
|
||||||
|
|
||||||
@@ -540,20 +540,20 @@ router.put('/:id', async (req, res) => {
|
|||||||
managing_stock
|
managing_stock
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
const [result] = await pool.query(
|
const { rowCount } = await pool.query(
|
||||||
`UPDATE products
|
`UPDATE products
|
||||||
SET title = ?,
|
SET title = $1,
|
||||||
sku = ?,
|
sku = $2,
|
||||||
stock_quantity = ?,
|
stock_quantity = $3,
|
||||||
price = ?,
|
price = $4,
|
||||||
regular_price = ?,
|
regular_price = $5,
|
||||||
cost_price = ?,
|
cost_price = $6,
|
||||||
vendor = ?,
|
vendor = $7,
|
||||||
brand = ?,
|
brand = $8,
|
||||||
categories = ?,
|
categories = $9,
|
||||||
visible = ?,
|
visible = $10,
|
||||||
managing_stock = ?
|
managing_stock = $11
|
||||||
WHERE pid = ?`,
|
WHERE pid = $12`,
|
||||||
[
|
[
|
||||||
title,
|
title,
|
||||||
sku,
|
sku,
|
||||||
@@ -570,7 +570,7 @@ router.put('/:id', async (req, res) => {
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.affectedRows === 0) {
|
if (rowCount === 0) {
|
||||||
return res.status(404).json({ error: 'Product not found' });
|
return res.status(404).json({ error: 'Product not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -588,7 +588,7 @@ router.get('/:id/metrics', async (req, res) => {
|
|||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|
||||||
// Get metrics from product_metrics table with inventory health data
|
// Get metrics from product_metrics table with inventory health data
|
||||||
const [metrics] = await pool.query(`
|
const { rows: metrics } = await pool.query(`
|
||||||
WITH inventory_status AS (
|
WITH inventory_status AS (
|
||||||
SELECT
|
SELECT
|
||||||
p.pid,
|
p.pid,
|
||||||
@@ -601,7 +601,7 @@ router.get('/:id/metrics', async (req, res) => {
|
|||||||
END as calculated_status
|
END as calculated_status
|
||||||
FROM products p
|
FROM products p
|
||||||
LEFT JOIN product_metrics pm ON p.pid = pm.pid
|
LEFT JOIN product_metrics pm ON p.pid = pm.pid
|
||||||
WHERE p.pid = ?
|
WHERE p.pid = $1
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
COALESCE(pm.daily_sales_avg, 0) as daily_sales_avg,
|
COALESCE(pm.daily_sales_avg, 0) as daily_sales_avg,
|
||||||
@@ -627,8 +627,8 @@ router.get('/:id/metrics', async (req, res) => {
|
|||||||
FROM products p
|
FROM products p
|
||||||
LEFT JOIN product_metrics pm ON p.pid = pm.pid
|
LEFT JOIN product_metrics pm ON p.pid = pm.pid
|
||||||
LEFT JOIN inventory_status is ON p.pid = is.pid
|
LEFT JOIN inventory_status is ON p.pid = is.pid
|
||||||
WHERE p.pid = ?
|
WHERE p.pid = $2
|
||||||
`, [id]);
|
`, [id, id]);
|
||||||
|
|
||||||
if (!metrics.length) {
|
if (!metrics.length) {
|
||||||
// Return default metrics structure if no data found
|
// Return default metrics structure if no data found
|
||||||
@@ -669,16 +669,16 @@ router.get('/:id/time-series', async (req, res) => {
|
|||||||
const pool = req.app.locals.pool;
|
const pool = req.app.locals.pool;
|
||||||
|
|
||||||
// Get monthly sales data
|
// Get monthly sales data
|
||||||
const [monthlySales] = await pool.query(`
|
const { rows: monthlySales } = await pool.query(`
|
||||||
SELECT
|
SELECT
|
||||||
DATE_FORMAT(date, '%Y-%m') as month,
|
TO_CHAR(date, 'YYYY-MM') as month,
|
||||||
COUNT(DISTINCT order_number) as order_count,
|
COUNT(DISTINCT order_number) as order_count,
|
||||||
SUM(quantity) as units_sold,
|
SUM(quantity) as units_sold,
|
||||||
CAST(SUM(price * quantity) AS DECIMAL(15,3)) as revenue
|
ROUND(SUM(price * quantity)::numeric, 3) as revenue
|
||||||
FROM orders
|
FROM orders
|
||||||
WHERE pid = ?
|
WHERE pid = $1
|
||||||
AND canceled = false
|
AND canceled = false
|
||||||
GROUP BY DATE_FORMAT(date, '%Y-%m')
|
GROUP BY TO_CHAR(date, 'YYYY-MM')
|
||||||
ORDER BY month DESC
|
ORDER BY month DESC
|
||||||
LIMIT 12
|
LIMIT 12
|
||||||
`, [id]);
|
`, [id]);
|
||||||
@@ -693,9 +693,9 @@ router.get('/:id/time-series', async (req, res) => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// Get recent orders
|
// Get recent orders
|
||||||
const [recentOrders] = await pool.query(`
|
const { rows: recentOrders } = await pool.query(`
|
||||||
SELECT
|
SELECT
|
||||||
DATE_FORMAT(date, '%Y-%m-%d') as date,
|
TO_CHAR(date, 'YYYY-MM-DD') as date,
|
||||||
order_number,
|
order_number,
|
||||||
quantity,
|
quantity,
|
||||||
price,
|
price,
|
||||||
@@ -705,18 +705,18 @@ router.get('/:id/time-series', async (req, res) => {
|
|||||||
customer_name as customer,
|
customer_name as customer,
|
||||||
status
|
status
|
||||||
FROM orders
|
FROM orders
|
||||||
WHERE pid = ?
|
WHERE pid = $1
|
||||||
AND canceled = false
|
AND canceled = false
|
||||||
ORDER BY date DESC
|
ORDER BY date DESC
|
||||||
LIMIT 10
|
LIMIT 10
|
||||||
`, [id]);
|
`, [id]);
|
||||||
|
|
||||||
// Get recent purchase orders with detailed status
|
// Get recent purchase orders with detailed status
|
||||||
const [recentPurchases] = await pool.query(`
|
const { rows: recentPurchases } = await pool.query(`
|
||||||
SELECT
|
SELECT
|
||||||
DATE_FORMAT(date, '%Y-%m-%d') as date,
|
TO_CHAR(date, 'YYYY-MM-DD') as date,
|
||||||
DATE_FORMAT(expected_date, '%Y-%m-%d') as expected_date,
|
TO_CHAR(expected_date, 'YYYY-MM-DD') as expected_date,
|
||||||
DATE_FORMAT(received_date, '%Y-%m-%d') as received_date,
|
TO_CHAR(received_date, 'YYYY-MM-DD') as received_date,
|
||||||
po_id,
|
po_id,
|
||||||
ordered,
|
ordered,
|
||||||
received,
|
received,
|
||||||
@@ -726,17 +726,17 @@ router.get('/:id/time-series', async (req, res) => {
|
|||||||
notes,
|
notes,
|
||||||
CASE
|
CASE
|
||||||
WHEN received_date IS NOT NULL THEN
|
WHEN received_date IS NOT NULL THEN
|
||||||
DATEDIFF(received_date, date)
|
(received_date - date)
|
||||||
WHEN expected_date < CURDATE() AND status < ${PurchaseOrderStatus.ReceivingStarted} THEN
|
WHEN expected_date < CURRENT_DATE AND status < $2 THEN
|
||||||
DATEDIFF(CURDATE(), expected_date)
|
(CURRENT_DATE - expected_date)
|
||||||
ELSE NULL
|
ELSE NULL
|
||||||
END as lead_time_days
|
END as lead_time_days
|
||||||
FROM purchase_orders
|
FROM purchase_orders
|
||||||
WHERE pid = ?
|
WHERE pid = $1
|
||||||
AND status != ${PurchaseOrderStatus.Canceled}
|
AND status != $3
|
||||||
ORDER BY date DESC
|
ORDER BY date DESC
|
||||||
LIMIT 10
|
LIMIT 10
|
||||||
`, [id]);
|
`, [id, PurchaseOrderStatus.ReceivingStarted, PurchaseOrderStatus.Canceled]);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
monthly_sales: formattedMonthlySales,
|
monthly_sales: formattedMonthlySales,
|
||||||
|
|||||||
@@ -97,6 +97,28 @@ router.get('/', async (req, res) => {
|
|||||||
const pages = Math.ceil(total / limit);
|
const pages = Math.ceil(total / limit);
|
||||||
|
|
||||||
// Get recent purchase orders
|
// Get recent purchase orders
|
||||||
|
let orderByClause;
|
||||||
|
|
||||||
|
if (sortColumn === 'order_date') {
|
||||||
|
orderByClause = `date ${sortDirection === 'desc' ? 'DESC' : 'ASC'}`;
|
||||||
|
} else if (sortColumn === 'vendor_name') {
|
||||||
|
orderByClause = `vendor ${sortDirection === 'desc' ? 'DESC' : 'ASC'}`;
|
||||||
|
} else if (sortColumn === 'total_cost') {
|
||||||
|
orderByClause = `total_cost ${sortDirection === 'desc' ? 'DESC' : 'ASC'}`;
|
||||||
|
} else if (sortColumn === 'total_received') {
|
||||||
|
orderByClause = `total_received ${sortDirection === 'desc' ? 'DESC' : 'ASC'}`;
|
||||||
|
} else if (sortColumn === 'total_items') {
|
||||||
|
orderByClause = `total_items ${sortDirection === 'desc' ? 'DESC' : 'ASC'}`;
|
||||||
|
} else if (sortColumn === 'total_quantity') {
|
||||||
|
orderByClause = `total_quantity ${sortDirection === 'desc' ? 'DESC' : 'ASC'}`;
|
||||||
|
} else if (sortColumn === 'fulfillment_rate') {
|
||||||
|
orderByClause = `fulfillment_rate ${sortDirection === 'desc' ? 'DESC' : 'ASC'}`;
|
||||||
|
} else if (sortColumn === 'status') {
|
||||||
|
orderByClause = `status ${sortDirection === 'desc' ? 'DESC' : 'ASC'}`;
|
||||||
|
} else {
|
||||||
|
orderByClause = `date ${sortDirection === 'desc' ? 'DESC' : 'ASC'}`;
|
||||||
|
}
|
||||||
|
|
||||||
const { rows: orders } = await pool.query(`
|
const { rows: orders } = await pool.query(`
|
||||||
WITH po_totals AS (
|
WITH po_totals AS (
|
||||||
SELECT
|
SELECT
|
||||||
@@ -128,20 +150,9 @@ router.get('/', async (req, res) => {
|
|||||||
total_received,
|
total_received,
|
||||||
fulfillment_rate
|
fulfillment_rate
|
||||||
FROM po_totals
|
FROM po_totals
|
||||||
ORDER BY
|
ORDER BY ${orderByClause}
|
||||||
CASE
|
LIMIT $${paramCounter} OFFSET $${paramCounter + 1}
|
||||||
WHEN $${paramCounter} = 'order_date' THEN date
|
`, [...params, Number(limit), offset]);
|
||||||
WHEN $${paramCounter} = 'vendor_name' THEN vendor
|
|
||||||
WHEN $${paramCounter} = 'total_cost' THEN total_cost
|
|
||||||
WHEN $${paramCounter} = 'total_received' THEN total_received
|
|
||||||
WHEN $${paramCounter} = 'total_items' THEN total_items
|
|
||||||
WHEN $${paramCounter} = 'total_quantity' THEN total_quantity
|
|
||||||
WHEN $${paramCounter} = 'fulfillment_rate' THEN fulfillment_rate
|
|
||||||
WHEN $${paramCounter} = 'status' THEN status
|
|
||||||
ELSE date
|
|
||||||
END ${sortDirection === 'desc' ? 'DESC' : 'ASC'}
|
|
||||||
LIMIT $${paramCounter + 1} OFFSET $${paramCounter + 2}
|
|
||||||
`, [...params, sortColumn, Number(limit), offset]);
|
|
||||||
|
|
||||||
// Get unique vendors for filter options
|
// Get unique vendors for filter options
|
||||||
const { rows: vendors } = await pool.query(`
|
const { rows: vendors } = await pool.query(`
|
||||||
@@ -272,7 +283,7 @@ router.get('/cost-analysis', async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const pool = req.app.locals.pool;
|
const pool = req.app.locals.pool;
|
||||||
|
|
||||||
const [analysis] = await pool.query(`
|
const { rows: analysis } = await pool.query(`
|
||||||
WITH category_costs AS (
|
WITH category_costs AS (
|
||||||
SELECT
|
SELECT
|
||||||
c.name as category,
|
c.name as category,
|
||||||
@@ -290,11 +301,11 @@ router.get('/cost-analysis', async (req, res) => {
|
|||||||
SELECT
|
SELECT
|
||||||
category,
|
category,
|
||||||
COUNT(DISTINCT pid) as unique_products,
|
COUNT(DISTINCT pid) as unique_products,
|
||||||
CAST(AVG(cost_price) AS DECIMAL(15,3)) as avg_cost,
|
ROUND(AVG(cost_price)::numeric, 3) as avg_cost,
|
||||||
CAST(MIN(cost_price) AS DECIMAL(15,3)) as min_cost,
|
ROUND(MIN(cost_price)::numeric, 3) as min_cost,
|
||||||
CAST(MAX(cost_price) AS DECIMAL(15,3)) as max_cost,
|
ROUND(MAX(cost_price)::numeric, 3) as max_cost,
|
||||||
CAST(STDDEV(cost_price) AS DECIMAL(15,3)) as cost_variance,
|
ROUND(STDDEV(cost_price)::numeric, 3) as cost_variance,
|
||||||
CAST(SUM(ordered * cost_price) AS DECIMAL(15,3)) as total_spend
|
ROUND(SUM(ordered * cost_price)::numeric, 3) as total_spend
|
||||||
FROM category_costs
|
FROM category_costs
|
||||||
GROUP BY category
|
GROUP BY category
|
||||||
ORDER BY total_spend DESC
|
ORDER BY total_spend DESC
|
||||||
@@ -302,17 +313,37 @@ router.get('/cost-analysis', async (req, res) => {
|
|||||||
|
|
||||||
// Parse numeric values
|
// Parse numeric values
|
||||||
const parsedAnalysis = {
|
const parsedAnalysis = {
|
||||||
categories: analysis.map(cat => ({
|
unique_products: 0,
|
||||||
|
avg_cost: 0,
|
||||||
|
min_cost: 0,
|
||||||
|
max_cost: 0,
|
||||||
|
cost_variance: 0,
|
||||||
|
total_spend_by_category: analysis.map(cat => ({
|
||||||
category: cat.category,
|
category: cat.category,
|
||||||
unique_products: Number(cat.unique_products) || 0,
|
|
||||||
avg_cost: Number(cat.avg_cost) || 0,
|
|
||||||
min_cost: Number(cat.min_cost) || 0,
|
|
||||||
max_cost: Number(cat.max_cost) || 0,
|
|
||||||
cost_variance: Number(cat.cost_variance) || 0,
|
|
||||||
total_spend: Number(cat.total_spend) || 0
|
total_spend: Number(cat.total_spend) || 0
|
||||||
}))
|
}))
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Calculate aggregated stats if data exists
|
||||||
|
if (analysis.length > 0) {
|
||||||
|
parsedAnalysis.unique_products = analysis.reduce((sum, cat) => sum + Number(cat.unique_products || 0), 0);
|
||||||
|
|
||||||
|
// Calculate weighted average cost
|
||||||
|
const totalProducts = parsedAnalysis.unique_products;
|
||||||
|
if (totalProducts > 0) {
|
||||||
|
parsedAnalysis.avg_cost = analysis.reduce((sum, cat) =>
|
||||||
|
sum + (Number(cat.avg_cost || 0) * Number(cat.unique_products || 0)), 0) / totalProducts;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find min and max across all categories
|
||||||
|
parsedAnalysis.min_cost = Math.min(...analysis.map(cat => Number(cat.min_cost || 0)));
|
||||||
|
parsedAnalysis.max_cost = Math.max(...analysis.map(cat => Number(cat.max_cost || 0)));
|
||||||
|
|
||||||
|
// Average variance
|
||||||
|
parsedAnalysis.cost_variance = analysis.reduce((sum, cat) =>
|
||||||
|
sum + Number(cat.cost_variance || 0), 0) / analysis.length;
|
||||||
|
}
|
||||||
|
|
||||||
res.json(parsedAnalysis);
|
res.json(parsedAnalysis);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching cost analysis:', error);
|
console.error('Error fetching cost analysis:', error);
|
||||||
@@ -325,7 +356,7 @@ router.get('/receiving-status', async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const pool = req.app.locals.pool;
|
const pool = req.app.locals.pool;
|
||||||
|
|
||||||
const [status] = await pool.query(`
|
const { rows: status } = await pool.query(`
|
||||||
WITH po_totals AS (
|
WITH po_totals AS (
|
||||||
SELECT
|
SELECT
|
||||||
po_id,
|
po_id,
|
||||||
@@ -333,7 +364,7 @@ router.get('/receiving-status', async (req, res) => {
|
|||||||
receiving_status,
|
receiving_status,
|
||||||
SUM(ordered) as total_ordered,
|
SUM(ordered) as total_ordered,
|
||||||
SUM(received) as total_received,
|
SUM(received) as total_received,
|
||||||
CAST(SUM(ordered * cost_price) AS DECIMAL(15,3)) as total_cost
|
ROUND(SUM(ordered * cost_price)::numeric, 3) as total_cost
|
||||||
FROM purchase_orders
|
FROM purchase_orders
|
||||||
WHERE status != ${STATUS.CANCELED}
|
WHERE status != ${STATUS.CANCELED}
|
||||||
GROUP BY po_id, status, receiving_status
|
GROUP BY po_id, status, receiving_status
|
||||||
@@ -345,8 +376,8 @@ router.get('/receiving-status', async (req, res) => {
|
|||||||
ROUND(
|
ROUND(
|
||||||
SUM(total_received) / NULLIF(SUM(total_ordered), 0), 3
|
SUM(total_received) / NULLIF(SUM(total_ordered), 0), 3
|
||||||
) as fulfillment_rate,
|
) as fulfillment_rate,
|
||||||
CAST(SUM(total_cost) AS DECIMAL(15,3)) as total_value,
|
ROUND(SUM(total_cost)::numeric, 3) as total_value,
|
||||||
CAST(AVG(total_cost) AS DECIMAL(15,3)) as avg_cost,
|
ROUND(AVG(total_cost)::numeric, 3) as avg_cost,
|
||||||
COUNT(DISTINCT CASE
|
COUNT(DISTINCT CASE
|
||||||
WHEN receiving_status = ${RECEIVING_STATUS.CREATED} THEN po_id
|
WHEN receiving_status = ${RECEIVING_STATUS.CREATED} THEN po_id
|
||||||
END) as pending_count,
|
END) as pending_count,
|
||||||
@@ -364,17 +395,17 @@ router.get('/receiving-status', async (req, res) => {
|
|||||||
|
|
||||||
// Parse numeric values
|
// Parse numeric values
|
||||||
const parsedStatus = {
|
const parsedStatus = {
|
||||||
order_count: Number(status[0].order_count) || 0,
|
order_count: Number(status[0]?.order_count) || 0,
|
||||||
total_ordered: Number(status[0].total_ordered) || 0,
|
total_ordered: Number(status[0]?.total_ordered) || 0,
|
||||||
total_received: Number(status[0].total_received) || 0,
|
total_received: Number(status[0]?.total_received) || 0,
|
||||||
fulfillment_rate: Number(status[0].fulfillment_rate) || 0,
|
fulfillment_rate: Number(status[0]?.fulfillment_rate) || 0,
|
||||||
total_value: Number(status[0].total_value) || 0,
|
total_value: Number(status[0]?.total_value) || 0,
|
||||||
avg_cost: Number(status[0].avg_cost) || 0,
|
avg_cost: Number(status[0]?.avg_cost) || 0,
|
||||||
status_breakdown: {
|
status_breakdown: {
|
||||||
pending: Number(status[0].pending_count) || 0,
|
pending: Number(status[0]?.pending_count) || 0,
|
||||||
partial: Number(status[0].partial_count) || 0,
|
partial: Number(status[0]?.partial_count) || 0,
|
||||||
completed: Number(status[0].completed_count) || 0,
|
completed: Number(status[0]?.completed_count) || 0,
|
||||||
canceled: Number(status[0].canceled_count) || 0
|
canceled: Number(status[0]?.canceled_count) || 0
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -390,7 +421,7 @@ router.get('/order-vs-received', async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const pool = req.app.locals.pool;
|
const pool = req.app.locals.pool;
|
||||||
|
|
||||||
const [quantities] = await pool.query(`
|
const { rows: quantities } = await pool.query(`
|
||||||
SELECT
|
SELECT
|
||||||
p.product_id,
|
p.product_id,
|
||||||
p.title as product,
|
p.title as product,
|
||||||
@@ -403,10 +434,10 @@ router.get('/order-vs-received', async (req, res) => {
|
|||||||
COUNT(DISTINCT po.po_id) as order_count
|
COUNT(DISTINCT po.po_id) as order_count
|
||||||
FROM products p
|
FROM products p
|
||||||
JOIN purchase_orders po ON p.product_id = po.product_id
|
JOIN purchase_orders po ON p.product_id = po.product_id
|
||||||
WHERE po.date >= DATE_SUB(CURDATE(), INTERVAL 90 DAY)
|
WHERE po.date >= (CURRENT_DATE - INTERVAL '90 days')
|
||||||
GROUP BY p.product_id, p.title, p.SKU
|
GROUP BY p.product_id, p.title, p.SKU
|
||||||
HAVING order_count > 0
|
HAVING COUNT(DISTINCT po.po_id) > 0
|
||||||
ORDER BY ordered_quantity DESC
|
ORDER BY SUM(po.ordered) DESC
|
||||||
LIMIT 20
|
LIMIT 20
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ router.get('/', async (req, res) => {
|
|||||||
ROUND((SUM(ordered * cost_price)::numeric / NULLIF(SUM(ordered), 0)), 2) as avg_unit_cost,
|
ROUND((SUM(ordered * cost_price)::numeric / NULLIF(SUM(ordered), 0)), 2) as avg_unit_cost,
|
||||||
ROUND(SUM(ordered * cost_price)::numeric, 3) as total_spend
|
ROUND(SUM(ordered * cost_price)::numeric, 3) as total_spend
|
||||||
FROM purchase_orders
|
FROM purchase_orders
|
||||||
WHERE status = 'closed'
|
WHERE status = 2
|
||||||
AND cost_price IS NOT NULL
|
AND cost_price IS NOT NULL
|
||||||
AND ordered > 0
|
AND ordered > 0
|
||||||
AND vendor = ANY($1)
|
AND vendor = ANY($1)
|
||||||
@@ -70,7 +70,7 @@ router.get('/', async (req, res) => {
|
|||||||
ROUND((SUM(ordered * cost_price)::numeric / NULLIF(SUM(ordered), 0)), 2) as avg_unit_cost,
|
ROUND((SUM(ordered * cost_price)::numeric / NULLIF(SUM(ordered), 0)), 2) as avg_unit_cost,
|
||||||
ROUND(SUM(ordered * cost_price)::numeric, 3) as total_spend
|
ROUND(SUM(ordered * cost_price)::numeric, 3) as total_spend
|
||||||
FROM purchase_orders
|
FROM purchase_orders
|
||||||
WHERE status = 'closed'
|
WHERE status = 2
|
||||||
AND cost_price IS NOT NULL
|
AND cost_price IS NOT NULL
|
||||||
AND ordered > 0
|
AND ordered > 0
|
||||||
AND vendor IS NOT NULL AND vendor != ''
|
AND vendor IS NOT NULL AND vendor != ''
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip, ScatterChart, Scatter, ZAxis } from 'recharts';
|
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip, ScatterChart, Scatter, ZAxis } from 'recharts';
|
||||||
import config from '../../config';
|
import config from '../../config';
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
interface VendorData {
|
interface VendorData {
|
||||||
performance: {
|
performance: {
|
||||||
@@ -10,14 +11,15 @@ interface VendorData {
|
|||||||
profitMargin: number;
|
profitMargin: number;
|
||||||
stockTurnover: number;
|
stockTurnover: number;
|
||||||
productCount: number;
|
productCount: number;
|
||||||
|
growth: number;
|
||||||
}[];
|
}[];
|
||||||
comparison: {
|
comparison?: {
|
||||||
vendor: string;
|
vendor: string;
|
||||||
salesPerProduct: number;
|
salesPerProduct: number;
|
||||||
averageMargin: number;
|
averageMargin: number;
|
||||||
size: number;
|
size: number;
|
||||||
}[];
|
}[];
|
||||||
trends: {
|
trends?: {
|
||||||
vendor: string;
|
vendor: string;
|
||||||
month: string;
|
month: string;
|
||||||
sales: number;
|
sales: number;
|
||||||
@@ -25,40 +27,86 @@ interface VendorData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function VendorPerformance() {
|
export function VendorPerformance() {
|
||||||
const { data, isLoading } = useQuery<VendorData>({
|
const [vendorData, setVendorData] = useState<VendorData | null>(null);
|
||||||
queryKey: ['vendor-performance'],
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
queryFn: async () => {
|
const [error, setError] = useState<string | null>(null);
|
||||||
const response = await fetch(`${config.apiUrl}/analytics/vendors`);
|
|
||||||
if (!response.ok) {
|
useEffect(() => {
|
||||||
throw new Error('Failed to fetch vendor performance');
|
// Use plain fetch to bypass cache issues with React Query
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
// Add cache-busting parameter
|
||||||
|
const response = await fetch(`${config.apiUrl}/analytics/vendors?nocache=${Date.now()}`, {
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||||
|
"Pragma": "no-cache",
|
||||||
|
"Expires": "0"
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
const rawData = await response.json();
|
const rawData = await response.json();
|
||||||
return {
|
|
||||||
|
if (!rawData || !rawData.performance) {
|
||||||
|
throw new Error('Invalid response format');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a complete structure even if some parts are missing
|
||||||
|
const data: VendorData = {
|
||||||
performance: rawData.performance.map((vendor: any) => ({
|
performance: rawData.performance.map((vendor: any) => ({
|
||||||
...vendor,
|
vendor: vendor.vendor,
|
||||||
salesVolume: Number(vendor.salesVolume) || 0,
|
salesVolume: Number(vendor.salesVolume) || 0,
|
||||||
profitMargin: Number(vendor.profitMargin) || 0,
|
profitMargin: Number(vendor.profitMargin) || 0,
|
||||||
stockTurnover: Number(vendor.stockTurnover) || 0,
|
stockTurnover: Number(vendor.stockTurnover) || 0,
|
||||||
productCount: Number(vendor.productCount) || 0
|
productCount: Number(vendor.productCount) || 0,
|
||||||
|
growth: Number(vendor.growth) || 0
|
||||||
})),
|
})),
|
||||||
comparison: rawData.comparison.map((vendor: any) => ({
|
comparison: rawData.comparison?.map((vendor: any) => ({
|
||||||
...vendor,
|
vendor: vendor.vendor,
|
||||||
salesPerProduct: Number(vendor.salesPerProduct) || 0,
|
salesPerProduct: Number(vendor.salesPerProduct) || 0,
|
||||||
averageMargin: Number(vendor.averageMargin) || 0,
|
averageMargin: Number(vendor.averageMargin) || 0,
|
||||||
size: Number(vendor.size) || 0
|
size: Number(vendor.size) || 0
|
||||||
})),
|
})) || [],
|
||||||
trends: rawData.trends.map((vendor: any) => ({
|
trends: rawData.trends?.map((vendor: any) => ({
|
||||||
...vendor,
|
vendor: vendor.vendor,
|
||||||
|
month: vendor.month,
|
||||||
sales: Number(vendor.sales) || 0
|
sales: Number(vendor.sales) || 0
|
||||||
}))
|
})) || []
|
||||||
};
|
};
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isLoading || !data) {
|
setVendorData(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching vendor data:', err);
|
||||||
|
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
return <div>Loading vendor performance...</div>;
|
return <div>Loading vendor performance...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (error || !vendorData) {
|
||||||
|
return <div className="text-red-500">Error loading vendor data: {error}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure we have at least the performance data
|
||||||
|
const sortedPerformance = vendorData.performance
|
||||||
|
.sort((a, b) => b.salesVolume - a.salesVolume)
|
||||||
|
.slice(0, 10);
|
||||||
|
|
||||||
|
// Use simplified version if comparison data is missing
|
||||||
|
const hasComparisonData = vendorData.comparison && vendorData.comparison.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
@@ -68,7 +116,7 @@ export function VendorPerformance() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
<BarChart data={data.performance}>
|
<BarChart data={sortedPerformance}>
|
||||||
<XAxis dataKey="vendor" />
|
<XAxis dataKey="vendor" />
|
||||||
<YAxis tickFormatter={(value) => `$${(value / 1000).toFixed(0)}k`} />
|
<YAxis tickFormatter={(value) => `$${(value / 1000).toFixed(0)}k`} />
|
||||||
<Tooltip
|
<Tooltip
|
||||||
@@ -84,6 +132,7 @@ export function VendorPerformance() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{hasComparisonData ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Vendor Performance Matrix</CardTitle>
|
<CardTitle>Vendor Performance Matrix</CardTitle>
|
||||||
@@ -114,7 +163,7 @@ export function VendorPerformance() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Scatter
|
<Scatter
|
||||||
data={data.comparison}
|
data={vendorData.comparison}
|
||||||
fill="#60a5fa"
|
fill="#60a5fa"
|
||||||
name="Vendors"
|
name="Vendors"
|
||||||
/>
|
/>
|
||||||
@@ -122,6 +171,29 @@ export function VendorPerformance() {
|
|||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Vendor Profit Margins</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<BarChart data={sortedPerformance}>
|
||||||
|
<XAxis dataKey="vendor" />
|
||||||
|
<YAxis tickFormatter={(value) => `${value}%`} />
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value: number) => [`${value.toFixed(1)}%`, 'Profit Margin']}
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
dataKey="profitMargin"
|
||||||
|
fill="#4ade80"
|
||||||
|
name="Profit Margin"
|
||||||
|
/>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
@@ -130,7 +202,7 @@ export function VendorPerformance() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{data.performance.map((vendor) => (
|
{sortedPerformance.map((vendor) => (
|
||||||
<div key={`${vendor.vendor}-${vendor.salesVolume}`} className="flex items-center">
|
<div key={`${vendor.vendor}-${vendor.salesVolume}`} className="flex items-center">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="text-sm font-medium">{vendor.vendor}</p>
|
<p className="text-sm font-medium">{vendor.vendor}</p>
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
import { useQuery } from "@tanstack/react-query"
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|
||||||
import { AlertCircle, AlertTriangle, CheckCircle2, PackageSearch } from "lucide-react"
|
|
||||||
import config from "@/config"
|
|
||||||
import { useNavigate } from "react-router-dom"
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
interface InventoryHealth {
|
|
||||||
critical: number
|
|
||||||
reorder: number
|
|
||||||
healthy: number
|
|
||||||
overstock: number
|
|
||||||
total: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export function InventoryHealthSummary() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { data: summary } = useQuery<InventoryHealth>({
|
|
||||||
queryKey: ["inventory-health"],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await fetch(`${config.apiUrl}/dashboard/inventory/health/summary`)
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error("Failed to fetch inventory health")
|
|
||||||
}
|
|
||||||
return response.json()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const stats = [
|
|
||||||
{
|
|
||||||
title: "Critical Stock",
|
|
||||||
value: summary?.critical || 0,
|
|
||||||
description: "Products needing immediate attention",
|
|
||||||
icon: AlertCircle,
|
|
||||||
className: "bg-destructive/10",
|
|
||||||
iconClassName: "text-destructive",
|
|
||||||
view: "critical"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Reorder Soon",
|
|
||||||
value: summary?.reorder || 0,
|
|
||||||
description: "Products approaching reorder point",
|
|
||||||
icon: AlertTriangle,
|
|
||||||
className: "bg-warning/10",
|
|
||||||
iconClassName: "text-warning",
|
|
||||||
view: "reorder"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Healthy Stock",
|
|
||||||
value: summary?.healthy || 0,
|
|
||||||
description: "Products at optimal levels",
|
|
||||||
icon: CheckCircle2,
|
|
||||||
className: "bg-success/10",
|
|
||||||
iconClassName: "text-success",
|
|
||||||
view: "healthy"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Overstock",
|
|
||||||
value: summary?.overstock || 0,
|
|
||||||
description: "Products exceeding optimal levels",
|
|
||||||
icon: PackageSearch,
|
|
||||||
className: "bg-muted",
|
|
||||||
iconClassName: "text-muted-foreground",
|
|
||||||
view: "overstocked"
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{stats.map((stat) => (
|
|
||||||
<Card
|
|
||||||
key={stat.title}
|
|
||||||
className={cn(stat.className, "cursor-pointer hover:opacity-90 transition-opacity")}
|
|
||||||
onClick={() => navigate(`/products?view=${stat.view}`)}
|
|
||||||
>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">{stat.title}</CardTitle>
|
|
||||||
<stat.icon className={`h-4 w-4 ${stat.iconClassName}`} />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">{stat.value}</div>
|
|
||||||
<p className="text-xs text-muted-foreground">{stat.description}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis, Tooltip } from 'recharts';
|
|
||||||
import config from '../../config';
|
|
||||||
|
|
||||||
interface InventoryMetrics {
|
|
||||||
stockLevels: {
|
|
||||||
category: string;
|
|
||||||
inStock: number;
|
|
||||||
lowStock: number;
|
|
||||||
outOfStock: number;
|
|
||||||
}[];
|
|
||||||
topVendors: {
|
|
||||||
vendor: string;
|
|
||||||
productCount: number;
|
|
||||||
averageStockLevel: string;
|
|
||||||
}[];
|
|
||||||
stockTurnover: {
|
|
||||||
category: string;
|
|
||||||
rate: string;
|
|
||||||
}[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function InventoryStats() {
|
|
||||||
const { data, isLoading, error } = useQuery<InventoryMetrics>({
|
|
||||||
queryKey: ['inventory-metrics'],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await fetch(`${config.apiUrl}/dashboard/inventory-metrics`);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to fetch inventory metrics');
|
|
||||||
}
|
|
||||||
return response.json();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return <div>Loading inventory metrics...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return <div className="text-red-500">Error loading inventory metrics</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid gap-4">
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Stock Levels by Category</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
|
||||||
<BarChart data={data?.stockLevels}>
|
|
||||||
<XAxis dataKey="category" />
|
|
||||||
<YAxis />
|
|
||||||
<Tooltip />
|
|
||||||
<Bar dataKey="inStock" name="In Stock" fill="#4ade80" />
|
|
||||||
<Bar dataKey="lowStock" name="Low Stock" fill="#fbbf24" />
|
|
||||||
<Bar dataKey="outOfStock" name="Out of Stock" fill="#f87171" />
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Stock Turnover Rate</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
|
||||||
<BarChart data={data?.stockTurnover}>
|
|
||||||
<XAxis dataKey="category" />
|
|
||||||
<YAxis />
|
|
||||||
<Tooltip formatter={(value: string) => [Number(value).toFixed(2), "Rate"]} />
|
|
||||||
<Bar dataKey="rate" name="Turnover Rate" fill="#60a5fa" />
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Top Vendors</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-4">
|
|
||||||
{data?.topVendors.map((vendor) => (
|
|
||||||
<div key={vendor.vendor} className="flex items-center">
|
|
||||||
<div className="flex-1">
|
|
||||||
<p className="text-sm font-medium">{vendor.vendor}</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{vendor.productCount} products
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="ml-4 text-right">
|
|
||||||
<p className="text-sm font-medium">
|
|
||||||
Avg. Stock: {Number(vendor.averageStockLevel).toFixed(0)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,232 +0,0 @@
|
|||||||
import { useQuery } from "@tanstack/react-query"
|
|
||||||
import { CardHeader, CardTitle, CardContent } from "@/components/ui/card"
|
|
||||||
import {
|
|
||||||
Area,
|
|
||||||
AreaChart,
|
|
||||||
ResponsiveContainer,
|
|
||||||
Tooltip,
|
|
||||||
XAxis,
|
|
||||||
YAxis,
|
|
||||||
} from "recharts"
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
|
||||||
import config from "@/config"
|
|
||||||
|
|
||||||
interface MetricDataPoint {
|
|
||||||
date: string
|
|
||||||
value: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface KeyMetrics {
|
|
||||||
revenue: MetricDataPoint[]
|
|
||||||
inventory_value: MetricDataPoint[]
|
|
||||||
gmroi: MetricDataPoint[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function KeyMetricsCharts() {
|
|
||||||
const { data: metrics } = useQuery<KeyMetrics>({
|
|
||||||
queryKey: ["key-metrics"],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await fetch(`${config.apiUrl}/metrics/trends`)
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error("Failed to fetch metrics trends")
|
|
||||||
}
|
|
||||||
return response.json()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const formatCurrency = (value: number) =>
|
|
||||||
new Intl.NumberFormat("en-US", {
|
|
||||||
style: "currency",
|
|
||||||
currency: "USD",
|
|
||||||
minimumFractionDigits: 0,
|
|
||||||
maximumFractionDigits: 0,
|
|
||||||
}).format(value)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-lg font-medium">Key Metrics</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Tabs defaultValue="revenue" className="space-y-4">
|
|
||||||
<TabsList>
|
|
||||||
<TabsTrigger value="revenue">Revenue</TabsTrigger>
|
|
||||||
<TabsTrigger value="inventory">Inventory Value</TabsTrigger>
|
|
||||||
<TabsTrigger value="gmroi">GMROI</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<TabsContent value="revenue" className="space-y-4">
|
|
||||||
<div className="h-[300px]">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<AreaChart data={metrics?.revenue}>
|
|
||||||
<XAxis
|
|
||||||
dataKey="date"
|
|
||||||
tickLine={false}
|
|
||||||
axisLine={false}
|
|
||||||
tickFormatter={(value) => value}
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
tickLine={false}
|
|
||||||
axisLine={false}
|
|
||||||
tickFormatter={formatCurrency}
|
|
||||||
/>
|
|
||||||
<Tooltip
|
|
||||||
content={({ active, payload }) => {
|
|
||||||
if (active && payload && payload.length) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border bg-background p-2 shadow-sm">
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-[0.70rem] uppercase text-muted-foreground">
|
|
||||||
Date
|
|
||||||
</span>
|
|
||||||
<span className="font-bold">
|
|
||||||
{payload[0].payload.date}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-[0.70rem] uppercase text-muted-foreground">
|
|
||||||
Revenue
|
|
||||||
</span>
|
|
||||||
<span className="font-bold">
|
|
||||||
{formatCurrency(payload[0].value as number)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Area
|
|
||||||
type="monotone"
|
|
||||||
dataKey="value"
|
|
||||||
stroke="#0ea5e9"
|
|
||||||
fill="#0ea5e9"
|
|
||||||
fillOpacity={0.2}
|
|
||||||
/>
|
|
||||||
</AreaChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="inventory" className="space-y-4">
|
|
||||||
<div className="h-[300px]">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<AreaChart data={metrics?.inventory_value}>
|
|
||||||
<XAxis
|
|
||||||
dataKey="date"
|
|
||||||
tickLine={false}
|
|
||||||
axisLine={false}
|
|
||||||
tickFormatter={(value) => value}
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
tickLine={false}
|
|
||||||
axisLine={false}
|
|
||||||
tickFormatter={formatCurrency}
|
|
||||||
/>
|
|
||||||
<Tooltip
|
|
||||||
content={({ active, payload }) => {
|
|
||||||
if (active && payload && payload.length) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border bg-background p-2 shadow-sm">
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-[0.70rem] uppercase text-muted-foreground">
|
|
||||||
Date
|
|
||||||
</span>
|
|
||||||
<span className="font-bold">
|
|
||||||
{payload[0].payload.date}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-[0.70rem] uppercase text-muted-foreground">
|
|
||||||
Value
|
|
||||||
</span>
|
|
||||||
<span className="font-bold">
|
|
||||||
{formatCurrency(payload[0].value as number)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Area
|
|
||||||
type="monotone"
|
|
||||||
dataKey="value"
|
|
||||||
stroke="#84cc16"
|
|
||||||
fill="#84cc16"
|
|
||||||
fillOpacity={0.2}
|
|
||||||
/>
|
|
||||||
</AreaChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="gmroi" className="space-y-4">
|
|
||||||
<div className="h-[300px]">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<AreaChart data={metrics?.gmroi}>
|
|
||||||
<XAxis
|
|
||||||
dataKey="date"
|
|
||||||
tickLine={false}
|
|
||||||
axisLine={false}
|
|
||||||
tickFormatter={(value) => value}
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
tickLine={false}
|
|
||||||
axisLine={false}
|
|
||||||
tickFormatter={(value) => `${value.toFixed(1)}%`}
|
|
||||||
/>
|
|
||||||
<Tooltip
|
|
||||||
content={({ active, payload }) => {
|
|
||||||
if (active && payload && payload.length) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border bg-background p-2 shadow-sm">
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-[0.70rem] uppercase text-muted-foreground">
|
|
||||||
Date
|
|
||||||
</span>
|
|
||||||
<span className="font-bold">
|
|
||||||
{payload[0].payload.date}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-[0.70rem] uppercase text-muted-foreground">
|
|
||||||
GMROI
|
|
||||||
</span>
|
|
||||||
<span className="font-bold">
|
|
||||||
{`${typeof payload[0].value === 'number' ? payload[0].value.toFixed(1) : payload[0].value}%`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Area
|
|
||||||
type="monotone"
|
|
||||||
dataKey="value"
|
|
||||||
stroke="#f59e0b"
|
|
||||||
fill="#f59e0b"
|
|
||||||
fillOpacity={0.2}
|
|
||||||
/>
|
|
||||||
</AreaChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</CardContent>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,108 +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 { Badge } from "@/components/ui/badge"
|
|
||||||
import config from "@/config"
|
|
||||||
import { format } from "date-fns"
|
|
||||||
|
|
||||||
interface Product {
|
|
||||||
pid: number;
|
|
||||||
sku: string;
|
|
||||||
title: string;
|
|
||||||
stock_quantity: number;
|
|
||||||
daily_sales_avg: string;
|
|
||||||
days_of_inventory: string;
|
|
||||||
reorder_qty: number;
|
|
||||||
last_purchase_date: string | null;
|
|
||||||
lead_time_status: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper functions
|
|
||||||
const formatDate = (dateString: string) => {
|
|
||||||
return format(new Date(dateString), 'MMM dd, yyyy')
|
|
||||||
}
|
|
||||||
|
|
||||||
const getLeadTimeVariant = (status: string) => {
|
|
||||||
switch (status.toLowerCase()) {
|
|
||||||
case 'critical':
|
|
||||||
return 'destructive'
|
|
||||||
case 'warning':
|
|
||||||
return 'secondary'
|
|
||||||
case 'good':
|
|
||||||
return 'secondary'
|
|
||||||
default:
|
|
||||||
return 'secondary'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LowStockAlerts() {
|
|
||||||
const { data: products } = useQuery<Product[]>({
|
|
||||||
queryKey: ["low-stock"],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await fetch(`${config.apiUrl}/dashboard/low-stock/products`)
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error("Failed to fetch low stock products")
|
|
||||||
}
|
|
||||||
return response.json()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-lg font-medium">Low Stock Alerts</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="max-h-[350px] overflow-auto">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Product</TableHead>
|
|
||||||
<TableHead className="text-right">Stock</TableHead>
|
|
||||||
<TableHead className="text-right">Daily Sales</TableHead>
|
|
||||||
<TableHead className="text-right">Days Left</TableHead>
|
|
||||||
<TableHead className="text-right">Reorder Qty</TableHead>
|
|
||||||
<TableHead>Last Purchase</TableHead>
|
|
||||||
<TableHead>Lead Time</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{products?.map((product) => (
|
|
||||||
<TableRow key={product.pid}>
|
|
||||||
<TableCell>
|
|
||||||
<a
|
|
||||||
href={`https://backend.acherryontop.com/product/${product.pid}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="hover:underline"
|
|
||||||
>
|
|
||||||
{product.title}
|
|
||||||
</a>
|
|
||||||
<div className="text-sm text-muted-foreground">{product.sku}</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">{product.stock_quantity}</TableCell>
|
|
||||||
<TableCell className="text-right">{Number(product.daily_sales_avg).toFixed(1)}</TableCell>
|
|
||||||
<TableCell className="text-right">{Number(product.days_of_inventory).toFixed(1)}</TableCell>
|
|
||||||
<TableCell className="text-right">{product.reorder_qty}</TableCell>
|
|
||||||
<TableCell>{product.last_purchase_date ? formatDate(product.last_purchase_date) : '-'}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant={getLeadTimeVariant(product.lead_time_status)}>
|
|
||||||
{product.lead_time_status}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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<SalesData[]>({
|
|
||||||
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 <div>Loading chart...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return <div className="text-red-500">Error loading sales overview</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ResponsiveContainer width="100%" height={350}>
|
|
||||||
<LineChart data={data}>
|
|
||||||
<XAxis
|
|
||||||
dataKey="date"
|
|
||||||
stroke="#888888"
|
|
||||||
fontSize={12}
|
|
||||||
tickLine={false}
|
|
||||||
axisLine={false}
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
stroke="#888888"
|
|
||||||
fontSize={12}
|
|
||||||
tickLine={false}
|
|
||||||
axisLine={false}
|
|
||||||
tickFormatter={(value) => `$${value.toLocaleString()}`}
|
|
||||||
/>
|
|
||||||
<Tooltip
|
|
||||||
formatter={(value: number) => [`$${value.toLocaleString()}`, 'Sales']}
|
|
||||||
labelFormatter={(label) => `Date: ${label}`}
|
|
||||||
/>
|
|
||||||
<Line
|
|
||||||
type="monotone"
|
|
||||||
dataKey="total"
|
|
||||||
stroke="hsl(var(--primary))"
|
|
||||||
strokeWidth={2}
|
|
||||||
dot={false}
|
|
||||||
/>
|
|
||||||
</LineChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
|
||||||
import config from '../../config';
|
|
||||||
|
|
||||||
interface RecentOrder {
|
|
||||||
order_id: string;
|
|
||||||
customer_name: string;
|
|
||||||
total_amount: number;
|
|
||||||
order_date: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RecentSales() {
|
|
||||||
const { data: recentOrders, isLoading, error } = useQuery<RecentOrder[]>({
|
|
||||||
queryKey: ['recent-orders'],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await fetch(`${config.apiUrl}/dashboard/recent-orders`);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to fetch recent orders');
|
|
||||||
}
|
|
||||||
const data = await response.json();
|
|
||||||
return data.map((order: RecentOrder) => ({
|
|
||||||
...order,
|
|
||||||
total_amount: parseFloat(order.total_amount.toString())
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return <div>Loading recent sales...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return <div className="text-red-500">Error loading recent sales</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-8">
|
|
||||||
{recentOrders?.map((order) => (
|
|
||||||
<div key={order.order_id} className="flex items-center">
|
|
||||||
<Avatar className="h-9 w-9">
|
|
||||||
<AvatarFallback>
|
|
||||||
{order.customer_name?.split(' ').map(n => n[0]).join('') || '??'}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<div className="ml-4 space-y-1">
|
|
||||||
<p className="text-sm font-medium leading-none">Order #{order.order_id}</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{new Date(order.order_date).toLocaleDateString()}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="ml-auto font-medium">
|
|
||||||
${order.total_amount.toFixed(2)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{!recentOrders?.length && (
|
|
||||||
<div className="text-center text-muted-foreground">
|
|
||||||
No recent orders found
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip, Legend } from 'recharts';
|
|
||||||
import config from '../../config';
|
|
||||||
|
|
||||||
interface CategorySales {
|
|
||||||
category: string;
|
|
||||||
total: number;
|
|
||||||
percentage: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const COLORS = ['#0088FE', '#00C49F', '#FFBB28', '#FF8042', '#8884d8', '#82ca9d'];
|
|
||||||
|
|
||||||
export function SalesByCategory() {
|
|
||||||
const { data, isLoading, error } = useQuery<CategorySales[]>({
|
|
||||||
queryKey: ['sales-by-category'],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await fetch(`${config.apiUrl}/dashboard/sales-by-category`);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to fetch category sales');
|
|
||||||
}
|
|
||||||
return response.json();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return <div>Loading chart...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return <div className="text-red-500">Error loading category sales</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
|
||||||
<PieChart>
|
|
||||||
<Pie
|
|
||||||
data={data}
|
|
||||||
cx="50%"
|
|
||||||
cy="50%"
|
|
||||||
labelLine={false}
|
|
||||||
outerRadius={80}
|
|
||||||
fill="#8884d8"
|
|
||||||
dataKey="total"
|
|
||||||
nameKey="category"
|
|
||||||
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
|
|
||||||
>
|
|
||||||
{data?.map((_, index) => (
|
|
||||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
|
||||||
))}
|
|
||||||
</Pie>
|
|
||||||
<Tooltip
|
|
||||||
formatter={(value: number) => [`$${value.toLocaleString()}`, 'Sales']}
|
|
||||||
/>
|
|
||||||
<Legend />
|
|
||||||
</PieChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,95 +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 { TrendingUp, TrendingDown } from "lucide-react"
|
|
||||||
import config from "@/config"
|
|
||||||
|
|
||||||
interface Product {
|
|
||||||
pid: number;
|
|
||||||
sku: string;
|
|
||||||
title: string;
|
|
||||||
daily_sales_avg: string;
|
|
||||||
weekly_sales_avg: string;
|
|
||||||
growth_rate: string;
|
|
||||||
total_revenue: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TrendingProducts() {
|
|
||||||
const { data: products } = useQuery<Product[]>({
|
|
||||||
queryKey: ["trending-products"],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await fetch(`${config.apiUrl}/products/trending`)
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error("Failed to fetch trending products")
|
|
||||||
}
|
|
||||||
return response.json()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const formatPercent = (value: number) =>
|
|
||||||
new Intl.NumberFormat("en-US", {
|
|
||||||
style: "percent",
|
|
||||||
minimumFractionDigits: 1,
|
|
||||||
maximumFractionDigits: 1,
|
|
||||||
signDisplay: "exceptZero",
|
|
||||||
}).format(value / 100)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-lg font-medium">Trending Products</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="max-h-[400px] overflow-auto">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Product</TableHead>
|
|
||||||
<TableHead>Daily Sales</TableHead>
|
|
||||||
<TableHead className="text-right">Growth</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{products?.map((product) => (
|
|
||||||
<TableRow key={product.pid}>
|
|
||||||
<TableCell className="font-medium">
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="font-medium">{product.title}</span>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
{product.sku}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{Number(product.daily_sales_avg).toFixed(1)}</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
<div className="flex items-center justify-end gap-1">
|
|
||||||
{Number(product.growth_rate) > 0 ? (
|
|
||||||
<TrendingUp className="h-4 w-4 text-success" />
|
|
||||||
) : (
|
|
||||||
<TrendingDown className="h-4 w-4 text-destructive" />
|
|
||||||
)}
|
|
||||||
<span
|
|
||||||
className={
|
|
||||||
Number(product.growth_rate) > 0 ? "text-success" : "text-destructive"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{formatPercent(Number(product.growth_rate))}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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<VendorMetrics[]>({
|
|
||||||
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 (
|
|
||||||
<>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-lg font-medium">Top Vendor Performance</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="max-h-[400px] overflow-auto">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Vendor</TableHead>
|
|
||||||
<TableHead>On-Time</TableHead>
|
|
||||||
<TableHead className="text-right">Fill Rate</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{sortedVendors?.map((vendor) => (
|
|
||||||
<TableRow key={vendor.vendor}>
|
|
||||||
<TableCell className="font-medium">{vendor.vendor}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Progress
|
|
||||||
value={vendor.on_time_delivery_rate}
|
|
||||||
className="h-2"
|
|
||||||
/>
|
|
||||||
<span className="w-10 text-sm">
|
|
||||||
{vendor.on_time_delivery_rate.toFixed(0)}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
{vendor.avg_fill_rate.toFixed(0)}%
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</CardContent>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user