Add analytics page
This commit is contained in:
379
inventory-server/src/routes/analytics.js
Normal file
379
inventory-server/src/routes/analytics.js
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Get overall analytics stats
|
||||||
|
router.get('/stats', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const pool = req.app.locals.pool;
|
||||||
|
|
||||||
|
const [results] = await pool.query(`
|
||||||
|
SELECT
|
||||||
|
COALESCE(
|
||||||
|
ROUND(
|
||||||
|
(SUM(o.price * o.quantity - p.cost_price * o.quantity) /
|
||||||
|
NULLIF(SUM(o.price * o.quantity), 0)) * 100, 1
|
||||||
|
),
|
||||||
|
0
|
||||||
|
) as profitMargin,
|
||||||
|
COALESCE(
|
||||||
|
ROUND(
|
||||||
|
(AVG(p.price / NULLIF(p.cost_price, 0) - 1) * 100), 1
|
||||||
|
),
|
||||||
|
0
|
||||||
|
) as averageMarkup,
|
||||||
|
COALESCE(
|
||||||
|
ROUND(
|
||||||
|
SUM(o.quantity) / NULLIF(AVG(p.stock_quantity), 0), 2
|
||||||
|
),
|
||||||
|
0
|
||||||
|
) as stockTurnoverRate,
|
||||||
|
COALESCE(COUNT(DISTINCT p.vendor), 0) as vendorCount,
|
||||||
|
COALESCE(COUNT(DISTINCT p.categories), 0) as categoryCount,
|
||||||
|
COALESCE(
|
||||||
|
ROUND(
|
||||||
|
AVG(o.price * o.quantity), 2
|
||||||
|
),
|
||||||
|
0
|
||||||
|
) as averageOrderValue
|
||||||
|
FROM products p
|
||||||
|
LEFT JOIN orders o ON p.product_id = o.product_id
|
||||||
|
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Ensure all values are numbers
|
||||||
|
const stats = {
|
||||||
|
profitMargin: Number(results[0].profitMargin) || 0,
|
||||||
|
averageMarkup: Number(results[0].averageMarkup) || 0,
|
||||||
|
stockTurnoverRate: Number(results[0].stockTurnoverRate) || 0,
|
||||||
|
vendorCount: Number(results[0].vendorCount) || 0,
|
||||||
|
categoryCount: Number(results[0].categoryCount) || 0,
|
||||||
|
averageOrderValue: Number(results[0].averageOrderValue) || 0
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(stats);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching analytics stats:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch analytics stats' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get profit analysis data
|
||||||
|
router.get('/profit', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const pool = req.app.locals.pool;
|
||||||
|
|
||||||
|
// Get profit margins by category
|
||||||
|
const [byCategory] = await pool.query(`
|
||||||
|
SELECT
|
||||||
|
COALESCE(p.categories, 'Uncategorized') as category,
|
||||||
|
ROUND(
|
||||||
|
(SUM(o.price * o.quantity - p.cost_price * o.quantity) /
|
||||||
|
NULLIF(SUM(o.price * o.quantity), 0)) * 100, 1
|
||||||
|
) as profitMargin,
|
||||||
|
SUM(o.price * o.quantity) as revenue,
|
||||||
|
SUM(p.cost_price * o.quantity) as cost
|
||||||
|
FROM products p
|
||||||
|
LEFT JOIN orders o ON p.product_id = o.product_id
|
||||||
|
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||||
|
GROUP BY p.categories
|
||||||
|
ORDER BY profitMargin DESC
|
||||||
|
LIMIT 10
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Get profit margin trend over time
|
||||||
|
const [overTime] = await pool.query(`
|
||||||
|
SELECT
|
||||||
|
formatted_date as date,
|
||||||
|
ROUND(
|
||||||
|
(SUM(o.price * o.quantity - p.cost_price * o.quantity) /
|
||||||
|
NULLIF(SUM(o.price * o.quantity), 0)) * 100, 1
|
||||||
|
) as profitMargin,
|
||||||
|
SUM(o.price * o.quantity) as revenue,
|
||||||
|
SUM(p.cost_price * o.quantity) as cost
|
||||||
|
FROM products p
|
||||||
|
LEFT JOIN orders o ON p.product_id = o.product_id
|
||||||
|
CROSS JOIN (
|
||||||
|
SELECT DATE_FORMAT(o.date, '%Y-%m-%d') as formatted_date
|
||||||
|
FROM orders o
|
||||||
|
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||||
|
GROUP BY DATE_FORMAT(o.date, '%Y-%m-%d')
|
||||||
|
) dates
|
||||||
|
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||||
|
AND DATE_FORMAT(o.date, '%Y-%m-%d') = dates.formatted_date
|
||||||
|
GROUP BY formatted_date
|
||||||
|
ORDER BY formatted_date
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Get top performing products
|
||||||
|
const [topProducts] = await pool.query(`
|
||||||
|
SELECT
|
||||||
|
p.title as product,
|
||||||
|
ROUND(
|
||||||
|
(SUM(o.price * o.quantity - p.cost_price * o.quantity) /
|
||||||
|
NULLIF(SUM(o.price * o.quantity), 0)) * 100, 1
|
||||||
|
) as profitMargin,
|
||||||
|
SUM(o.price * o.quantity) as revenue,
|
||||||
|
SUM(p.cost_price * o.quantity) as cost
|
||||||
|
FROM products p
|
||||||
|
LEFT JOIN orders o ON p.product_id = o.product_id
|
||||||
|
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||||
|
GROUP BY p.product_id, p.title
|
||||||
|
HAVING revenue > 0
|
||||||
|
ORDER BY profitMargin DESC
|
||||||
|
LIMIT 10
|
||||||
|
`);
|
||||||
|
|
||||||
|
res.json({ byCategory, overTime, topProducts });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching profit analysis:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch profit analysis' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get vendor performance data
|
||||||
|
router.get('/vendors', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const pool = req.app.locals.pool;
|
||||||
|
|
||||||
|
// Get vendor performance metrics
|
||||||
|
const [performance] = await pool.query(`
|
||||||
|
SELECT
|
||||||
|
p.vendor,
|
||||||
|
SUM(o.price * o.quantity) as salesVolume,
|
||||||
|
ROUND(
|
||||||
|
(SUM(o.price * o.quantity - p.cost_price * o.quantity) /
|
||||||
|
NULLIF(SUM(o.price * o.quantity), 0)) * 100, 1
|
||||||
|
) as profitMargin,
|
||||||
|
ROUND(
|
||||||
|
SUM(o.quantity) / NULLIF(AVG(p.stock_quantity), 0), 1
|
||||||
|
) as stockTurnover,
|
||||||
|
COUNT(DISTINCT p.product_id) as productCount
|
||||||
|
FROM products p
|
||||||
|
LEFT JOIN orders o ON p.product_id = o.product_id
|
||||||
|
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||||
|
AND p.vendor IS NOT NULL
|
||||||
|
GROUP BY p.vendor
|
||||||
|
HAVING salesVolume > 0
|
||||||
|
ORDER BY salesVolume DESC
|
||||||
|
LIMIT 10
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Get vendor comparison data
|
||||||
|
const [comparison] = await pool.query(`
|
||||||
|
SELECT
|
||||||
|
p.vendor,
|
||||||
|
ROUND(SUM(o.price * o.quantity) / NULLIF(COUNT(DISTINCT p.product_id), 0), 2) as salesPerProduct,
|
||||||
|
ROUND(AVG((o.price - p.cost_price) / NULLIF(o.price, 0) * 100), 1) as averageMargin,
|
||||||
|
COUNT(DISTINCT p.product_id) as size
|
||||||
|
FROM products p
|
||||||
|
LEFT JOIN orders o ON p.product_id = o.product_id
|
||||||
|
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||||
|
AND p.vendor IS NOT NULL
|
||||||
|
GROUP BY p.vendor
|
||||||
|
HAVING salesPerProduct > 0
|
||||||
|
ORDER BY salesPerProduct DESC
|
||||||
|
LIMIT 20
|
||||||
|
`);
|
||||||
|
|
||||||
|
res.json({ performance, comparison });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching vendor performance:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch vendor performance' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get stock analysis data
|
||||||
|
router.get('/stock', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const pool = req.app.locals.pool;
|
||||||
|
|
||||||
|
// Get turnover by category
|
||||||
|
const [turnoverByCategory] = await pool.query(`
|
||||||
|
SELECT
|
||||||
|
COALESCE(p.categories, 'Uncategorized') as category,
|
||||||
|
ROUND(SUM(o.quantity) / NULLIF(AVG(p.stock_quantity), 0), 1) as turnoverRate,
|
||||||
|
ROUND(AVG(p.stock_quantity), 0) as averageStock,
|
||||||
|
SUM(o.quantity) as totalSales
|
||||||
|
FROM products p
|
||||||
|
LEFT JOIN orders o ON p.product_id = o.product_id
|
||||||
|
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||||
|
GROUP BY p.categories
|
||||||
|
HAVING turnoverRate > 0
|
||||||
|
ORDER BY turnoverRate DESC
|
||||||
|
LIMIT 10
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Get stock levels over time (last 30 days)
|
||||||
|
const [stockLevels] = await pool.query(`
|
||||||
|
SELECT
|
||||||
|
DATE_FORMAT(o.date, '%Y-%m-%d') as date,
|
||||||
|
SUM(CASE WHEN p.stock_quantity > 5 THEN 1 ELSE 0 END) as inStock,
|
||||||
|
SUM(CASE WHEN p.stock_quantity <= 5 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
|
||||||
|
FROM products p
|
||||||
|
LEFT JOIN orders o ON p.product_id = o.product_id
|
||||||
|
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||||
|
GROUP BY DATE_FORMAT(o.date, '%Y-%m-%d')
|
||||||
|
ORDER BY date
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Get critical stock items
|
||||||
|
const [criticalItems] = await pool.query(`
|
||||||
|
SELECT
|
||||||
|
p.title as product,
|
||||||
|
p.SKU as sku,
|
||||||
|
p.stock_quantity as stockQuantity,
|
||||||
|
GREATEST(ROUND(AVG(o.quantity) * 7), 5) as reorderPoint,
|
||||||
|
ROUND(SUM(o.quantity) / NULLIF(p.stock_quantity, 0), 1) as turnoverRate,
|
||||||
|
CASE
|
||||||
|
WHEN p.stock_quantity = 0 THEN 0
|
||||||
|
ELSE ROUND(p.stock_quantity / NULLIF((SUM(o.quantity) / 30), 0))
|
||||||
|
END as daysUntilStockout
|
||||||
|
FROM products p
|
||||||
|
LEFT JOIN orders o ON p.product_id = o.product_id
|
||||||
|
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||||
|
AND p.managing_stock = true
|
||||||
|
GROUP BY p.product_id
|
||||||
|
HAVING daysUntilStockout < 30 AND daysUntilStockout >= 0
|
||||||
|
ORDER BY daysUntilStockout
|
||||||
|
LIMIT 10
|
||||||
|
`);
|
||||||
|
|
||||||
|
res.json({ turnoverByCategory, stockLevels, criticalItems });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching stock analysis:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch stock analysis' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get price analysis data
|
||||||
|
router.get('/pricing', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const pool = req.app.locals.pool;
|
||||||
|
|
||||||
|
// Get price points analysis
|
||||||
|
const [pricePoints] = await pool.query(`
|
||||||
|
SELECT
|
||||||
|
p.price,
|
||||||
|
SUM(o.quantity) as salesVolume,
|
||||||
|
SUM(o.price * o.quantity) as revenue,
|
||||||
|
p.categories as category
|
||||||
|
FROM products p
|
||||||
|
LEFT JOIN orders o ON p.product_id = o.product_id
|
||||||
|
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||||
|
GROUP BY p.price, p.categories
|
||||||
|
HAVING salesVolume > 0
|
||||||
|
ORDER BY revenue DESC
|
||||||
|
LIMIT 50
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Get price elasticity data (price changes vs demand)
|
||||||
|
const [elasticity] = await pool.query(`
|
||||||
|
SELECT
|
||||||
|
DATE_FORMAT(o.date, '%Y-%m-%d') as date,
|
||||||
|
AVG(o.price) as price,
|
||||||
|
SUM(o.quantity) as demand
|
||||||
|
FROM orders o
|
||||||
|
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||||
|
GROUP BY DATE_FORMAT(o.date, '%Y-%m-%d')
|
||||||
|
ORDER BY date
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Get price optimization recommendations
|
||||||
|
const [recommendations] = await pool.query(`
|
||||||
|
SELECT
|
||||||
|
p.title as product,
|
||||||
|
p.price as currentPrice,
|
||||||
|
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 recommendedPrice,
|
||||||
|
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 potentialRevenue,
|
||||||
|
CASE
|
||||||
|
WHEN AVG(o.quantity) > 10 THEN 85
|
||||||
|
WHEN AVG(o.quantity) < 2 THEN 75
|
||||||
|
ELSE 65
|
||||||
|
END as confidence
|
||||||
|
FROM products p
|
||||||
|
LEFT JOIN orders o ON p.product_id = o.product_id
|
||||||
|
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||||
|
GROUP BY p.product_id
|
||||||
|
HAVING ABS(recommendedPrice - currentPrice) > 0
|
||||||
|
ORDER BY potentialRevenue - SUM(o.price * o.quantity) DESC
|
||||||
|
LIMIT 10
|
||||||
|
`);
|
||||||
|
|
||||||
|
res.json({ pricePoints, elasticity, recommendations });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching price analysis:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch price analysis' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get category performance data
|
||||||
|
router.get('/categories', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const pool = req.app.locals.pool;
|
||||||
|
|
||||||
|
// Get category performance metrics
|
||||||
|
const [performance] = await pool.query(`
|
||||||
|
SELECT
|
||||||
|
COALESCE(p.categories, 'Uncategorized') as category,
|
||||||
|
SUM(o.price * o.quantity) as revenue,
|
||||||
|
SUM(o.price * o.quantity - p.cost_price * o.quantity) as profit,
|
||||||
|
ROUND(
|
||||||
|
((SUM(CASE
|
||||||
|
WHEN o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||||
|
THEN o.price * o.quantity
|
||||||
|
ELSE 0
|
||||||
|
END) /
|
||||||
|
NULLIF(SUM(CASE
|
||||||
|
WHEN o.date >= DATE_SUB(CURDATE(), INTERVAL 60 DAY)
|
||||||
|
AND o.date < DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||||
|
THEN o.price * o.quantity
|
||||||
|
ELSE 0
|
||||||
|
END), 0)) - 1) * 100,
|
||||||
|
1
|
||||||
|
) as growth,
|
||||||
|
COUNT(DISTINCT p.product_id) as productCount
|
||||||
|
FROM products p
|
||||||
|
LEFT JOIN orders o ON p.product_id = o.product_id
|
||||||
|
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 60 DAY)
|
||||||
|
GROUP BY p.categories
|
||||||
|
HAVING revenue > 0
|
||||||
|
ORDER BY revenue DESC
|
||||||
|
LIMIT 10
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Get category revenue distribution
|
||||||
|
const [distribution] = await pool.query(`
|
||||||
|
SELECT
|
||||||
|
COALESCE(p.categories, 'Uncategorized') as category,
|
||||||
|
SUM(o.price * o.quantity) as value
|
||||||
|
FROM products p
|
||||||
|
LEFT JOIN orders o ON p.product_id = o.product_id
|
||||||
|
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||||
|
GROUP BY p.categories
|
||||||
|
HAVING value > 0
|
||||||
|
ORDER BY value DESC
|
||||||
|
LIMIT 6
|
||||||
|
`);
|
||||||
|
|
||||||
|
res.json({ performance, distribution });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching category performance:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch category performance' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -3,10 +3,12 @@ const fs = require('fs');
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const mysql = require('mysql2/promise');
|
const mysql = require('mysql2/promise');
|
||||||
const { corsMiddleware, corsErrorHandler } = require('./middleware/cors');
|
const { corsMiddleware, corsErrorHandler } = require('./middleware/cors');
|
||||||
|
const { initPool } = require('./utils/db');
|
||||||
const productsRouter = require('./routes/products');
|
const productsRouter = require('./routes/products');
|
||||||
const dashboardRouter = require('./routes/dashboard');
|
const dashboardRouter = require('./routes/dashboard');
|
||||||
const ordersRouter = require('./routes/orders');
|
const ordersRouter = require('./routes/orders');
|
||||||
const csvRouter = require('./routes/csv');
|
const csvRouter = require('./routes/csv');
|
||||||
|
const analyticsRouter = require('./routes/analytics');
|
||||||
|
|
||||||
// Get the absolute path to the .env file
|
// Get the absolute path to the .env file
|
||||||
const envPath = path.resolve(process.cwd(), '.env');
|
const envPath = path.resolve(process.cwd(), '.env');
|
||||||
@@ -55,11 +57,28 @@ app.use(corsMiddleware);
|
|||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(express.urlencoded({ extended: true }));
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
|
||||||
|
// Initialize database pool
|
||||||
|
const pool = initPool({
|
||||||
|
host: process.env.DB_HOST,
|
||||||
|
user: process.env.DB_USER,
|
||||||
|
password: process.env.DB_PASSWORD,
|
||||||
|
database: process.env.DB_NAME,
|
||||||
|
waitForConnections: true,
|
||||||
|
connectionLimit: process.env.NODE_ENV === 'production' ? 20 : 10,
|
||||||
|
queueLimit: 0,
|
||||||
|
enableKeepAlive: true,
|
||||||
|
keepAliveInitialDelay: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
// Make pool available to routes
|
||||||
|
app.locals.pool = pool;
|
||||||
|
|
||||||
// Routes
|
// Routes
|
||||||
app.use('/api/products', productsRouter);
|
app.use('/api/products', productsRouter);
|
||||||
app.use('/api/dashboard', dashboardRouter);
|
app.use('/api/dashboard', dashboardRouter);
|
||||||
app.use('/api/orders', ordersRouter);
|
app.use('/api/orders', ordersRouter);
|
||||||
app.use('/api/csv', csvRouter);
|
app.use('/api/csv', csvRouter);
|
||||||
|
app.use('/api/analytics', analyticsRouter);
|
||||||
|
|
||||||
// Basic health check route
|
// Basic health check route
|
||||||
app.get('/health', (req, res) => {
|
app.get('/health', (req, res) => {
|
||||||
@@ -95,22 +114,6 @@ process.on('unhandledRejection', (reason, promise) => {
|
|||||||
console.error(`[${new Date().toISOString()}] Unhandled Rejection at:`, promise, 'reason:', reason);
|
console.error(`[${new Date().toISOString()}] Unhandled Rejection at:`, promise, 'reason:', reason);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Database connection pool
|
|
||||||
const pool = mysql.createPool({
|
|
||||||
host: process.env.DB_HOST,
|
|
||||||
user: process.env.DB_USER,
|
|
||||||
password: process.env.DB_PASSWORD,
|
|
||||||
database: process.env.DB_NAME,
|
|
||||||
waitForConnections: true,
|
|
||||||
connectionLimit: process.env.NODE_ENV === 'production' ? 20 : 10,
|
|
||||||
queueLimit: 0,
|
|
||||||
enableKeepAlive: true,
|
|
||||||
keepAliveInitialDelay: 0
|
|
||||||
});
|
|
||||||
|
|
||||||
// Make pool available to routes
|
|
||||||
app.locals.pool = pool;
|
|
||||||
|
|
||||||
// Test database connection
|
// Test database connection
|
||||||
pool.getConnection()
|
pool.getConnection()
|
||||||
.then(connection => {
|
.then(connection => {
|
||||||
|
|||||||
21
inventory-server/src/utils/db.js
Normal file
21
inventory-server/src/utils/db.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
const mysql = require('mysql2/promise');
|
||||||
|
|
||||||
|
let pool;
|
||||||
|
|
||||||
|
function initPool(config) {
|
||||||
|
pool = mysql.createPool(config);
|
||||||
|
return pool;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getConnection() {
|
||||||
|
if (!pool) {
|
||||||
|
throw new Error('Database pool not initialized');
|
||||||
|
}
|
||||||
|
return pool.getConnection();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
initPool,
|
||||||
|
getConnection,
|
||||||
|
getPool: () => pool
|
||||||
|
};
|
||||||
@@ -6,6 +6,7 @@ import { Import } from './pages/Import';
|
|||||||
import { Dashboard } from './pages/Dashboard';
|
import { Dashboard } from './pages/Dashboard';
|
||||||
import { Orders } from './pages/Orders';
|
import { Orders } from './pages/Orders';
|
||||||
import { Settings } from './pages/Settings';
|
import { Settings } from './pages/Settings';
|
||||||
|
import { Analytics } from './pages/Analytics';
|
||||||
import { Toaster } from '@/components/ui/sonner';
|
import { Toaster } from '@/components/ui/sonner';
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
@@ -21,6 +22,7 @@ function App() {
|
|||||||
<Route path="/products" element={<Products />} />
|
<Route path="/products" element={<Products />} />
|
||||||
<Route path="/import" element={<Import />} />
|
<Route path="/import" element={<Import />} />
|
||||||
<Route path="/orders" element={<Orders />} />
|
<Route path="/orders" element={<Orders />} />
|
||||||
|
<Route path="/analytics" element={<Analytics />} />
|
||||||
<Route path="/settings" element={<Settings />} />
|
<Route path="/settings" element={<Settings />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</MainLayout>
|
</MainLayout>
|
||||||
|
|||||||
153
inventory/src/components/analytics/CategoryPerformance.tsx
Normal file
153
inventory/src/components/analytics/CategoryPerformance.tsx
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip, PieChart, Pie, Cell, Legend } from 'recharts';
|
||||||
|
import config from '../../config';
|
||||||
|
|
||||||
|
interface CategoryData {
|
||||||
|
performance: {
|
||||||
|
category: string;
|
||||||
|
revenue: number;
|
||||||
|
profit: number;
|
||||||
|
growth: number;
|
||||||
|
productCount: number;
|
||||||
|
}[];
|
||||||
|
distribution: {
|
||||||
|
category: string;
|
||||||
|
value: number;
|
||||||
|
}[];
|
||||||
|
trends: {
|
||||||
|
category: string;
|
||||||
|
month: string;
|
||||||
|
sales: number;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLORS = ['#4ade80', '#60a5fa', '#f87171', '#fbbf24', '#a78bfa', '#f472b6'];
|
||||||
|
|
||||||
|
export function CategoryPerformance() {
|
||||||
|
const { data, isLoading } = useQuery<CategoryData>({
|
||||||
|
queryKey: ['category-performance'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await fetch(`${config.apiUrl}/analytics/categories`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch category performance');
|
||||||
|
}
|
||||||
|
const rawData = await response.json();
|
||||||
|
return {
|
||||||
|
performance: rawData.performance.map((item: any) => ({
|
||||||
|
...item,
|
||||||
|
revenue: Number(item.revenue) || 0,
|
||||||
|
profit: Number(item.profit) || 0,
|
||||||
|
growth: Number(item.growth) || 0,
|
||||||
|
productCount: Number(item.productCount) || 0
|
||||||
|
})),
|
||||||
|
distribution: rawData.distribution.map((item: any) => ({
|
||||||
|
...item,
|
||||||
|
value: Number(item.value) || 0
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading || !data) {
|
||||||
|
return <div>Loading category performance...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatGrowth = (growth: number) => {
|
||||||
|
const value = growth >= 0 ? `+${growth.toFixed(1)}%` : `${growth.toFixed(1)}%`;
|
||||||
|
const color = growth >= 0 ? 'text-green-500' : 'text-red-500';
|
||||||
|
return <span className={color}>{value}</span>;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Category Revenue Distribution</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={data.distribution}
|
||||||
|
dataKey="value"
|
||||||
|
nameKey="category"
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
outerRadius={100}
|
||||||
|
fill="#8884d8"
|
||||||
|
label={(entry) => entry.category}
|
||||||
|
>
|
||||||
|
{data.distribution.map((entry, index) => (
|
||||||
|
<Cell
|
||||||
|
key={entry.category}
|
||||||
|
fill={COLORS[index % COLORS.length]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value: number) => [`$${value.toLocaleString()}`, 'Revenue']}
|
||||||
|
/>
|
||||||
|
<Legend />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Category Growth Rates</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<BarChart data={data.performance}>
|
||||||
|
<XAxis dataKey="category" />
|
||||||
|
<YAxis tickFormatter={(value) => `${value}%`} />
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value: number) => [`${value.toFixed(1)}%`, 'Growth Rate']}
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
dataKey="growth"
|
||||||
|
fill="#4ade80"
|
||||||
|
name="Growth Rate"
|
||||||
|
/>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Category Performance Details</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{data.performance.map((category) => (
|
||||||
|
<div key={category.category} className="flex items-center">
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium">{category.category}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{category.productCount} products
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="ml-4 text-right space-y-1">
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
${category.revenue.toLocaleString()} revenue
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
${category.profit.toLocaleString()} profit
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Growth: {formatGrowth(category.growth)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
182
inventory/src/components/analytics/PriceAnalysis.tsx
Normal file
182
inventory/src/components/analytics/PriceAnalysis.tsx
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { ResponsiveContainer, ScatterChart, Scatter, XAxis, YAxis, Tooltip, ZAxis, LineChart, Line } from 'recharts';
|
||||||
|
import config from '../../config';
|
||||||
|
|
||||||
|
interface PriceData {
|
||||||
|
pricePoints: {
|
||||||
|
price: number;
|
||||||
|
salesVolume: number;
|
||||||
|
revenue: number;
|
||||||
|
category: string;
|
||||||
|
}[];
|
||||||
|
elasticity: {
|
||||||
|
date: string;
|
||||||
|
price: number;
|
||||||
|
demand: number;
|
||||||
|
}[];
|
||||||
|
recommendations: {
|
||||||
|
product: string;
|
||||||
|
currentPrice: number;
|
||||||
|
recommendedPrice: number;
|
||||||
|
potentialRevenue: number;
|
||||||
|
confidence: number;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PriceAnalysis() {
|
||||||
|
const { data, isLoading } = useQuery<PriceData>({
|
||||||
|
queryKey: ['price-analysis'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await fetch(`${config.apiUrl}/analytics/pricing`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch price analysis');
|
||||||
|
}
|
||||||
|
const rawData = await response.json();
|
||||||
|
return {
|
||||||
|
pricePoints: rawData.pricePoints.map((item: any) => ({
|
||||||
|
...item,
|
||||||
|
price: Number(item.price) || 0,
|
||||||
|
salesVolume: Number(item.salesVolume) || 0,
|
||||||
|
revenue: Number(item.revenue) || 0
|
||||||
|
})),
|
||||||
|
elasticity: rawData.elasticity.map((item: any) => ({
|
||||||
|
...item,
|
||||||
|
price: Number(item.price) || 0,
|
||||||
|
demand: Number(item.demand) || 0
|
||||||
|
})),
|
||||||
|
recommendations: rawData.recommendations.map((item: any) => ({
|
||||||
|
...item,
|
||||||
|
currentPrice: Number(item.currentPrice) || 0,
|
||||||
|
recommendedPrice: Number(item.recommendedPrice) || 0,
|
||||||
|
potentialRevenue: Number(item.potentialRevenue) || 0,
|
||||||
|
confidence: Number(item.confidence) || 0
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading || !data) {
|
||||||
|
return <div>Loading price analysis...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Price Point Analysis</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<ScatterChart>
|
||||||
|
<XAxis
|
||||||
|
dataKey="price"
|
||||||
|
name="Price"
|
||||||
|
tickFormatter={(value) => `$${value}`}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
dataKey="salesVolume"
|
||||||
|
name="Sales Volume"
|
||||||
|
/>
|
||||||
|
<ZAxis
|
||||||
|
dataKey="revenue"
|
||||||
|
range={[50, 400]}
|
||||||
|
name="Revenue"
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value: number, name: string) => {
|
||||||
|
if (name === 'Price') return [`$${value}`, name];
|
||||||
|
if (name === 'Sales Volume') return [value.toLocaleString(), name];
|
||||||
|
if (name === 'Revenue') return [`$${value.toLocaleString()}`, name];
|
||||||
|
return [value, name];
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Scatter
|
||||||
|
data={data.pricePoints}
|
||||||
|
fill="#a78bfa"
|
||||||
|
name="Products"
|
||||||
|
/>
|
||||||
|
</ScatterChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Price Elasticity</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<LineChart data={data.elasticity}>
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
tickFormatter={(value) => new Date(value).toLocaleDateString()}
|
||||||
|
/>
|
||||||
|
<YAxis yAxisId="left" orientation="left" stroke="#a78bfa" />
|
||||||
|
<YAxis
|
||||||
|
yAxisId="right"
|
||||||
|
orientation="right"
|
||||||
|
stroke="#4ade80"
|
||||||
|
tickFormatter={(value) => `$${value}`}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
labelFormatter={(label) => new Date(label).toLocaleDateString()}
|
||||||
|
formatter={(value: number, name: string) => {
|
||||||
|
if (name === 'Price') return [`$${value}`, name];
|
||||||
|
return [value.toLocaleString(), name];
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
yAxisId="left"
|
||||||
|
type="monotone"
|
||||||
|
dataKey="demand"
|
||||||
|
stroke="#a78bfa"
|
||||||
|
name="Demand"
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
yAxisId="right"
|
||||||
|
type="monotone"
|
||||||
|
dataKey="price"
|
||||||
|
stroke="#4ade80"
|
||||||
|
name="Price"
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Price Optimization Recommendations</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{data.recommendations.map((item) => (
|
||||||
|
<div key={item.product} className="flex items-center">
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium">{item.product}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Current Price: ${item.currentPrice.toFixed(2)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="ml-4 text-right space-y-1">
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
Recommended: ${item.recommendedPrice.toFixed(2)}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Potential Revenue: ${item.potentialRevenue.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Confidence: {item.confidence}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
145
inventory/src/components/analytics/ProfitAnalysis.tsx
Normal file
145
inventory/src/components/analytics/ProfitAnalysis.tsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip, LineChart, Line } from 'recharts';
|
||||||
|
import config from '../../config';
|
||||||
|
|
||||||
|
interface ProfitData {
|
||||||
|
byCategory: {
|
||||||
|
category: string;
|
||||||
|
profitMargin: number;
|
||||||
|
revenue: number;
|
||||||
|
cost: number;
|
||||||
|
}[];
|
||||||
|
overTime: {
|
||||||
|
date: string;
|
||||||
|
profitMargin: number;
|
||||||
|
revenue: number;
|
||||||
|
cost: number;
|
||||||
|
}[];
|
||||||
|
topProducts: {
|
||||||
|
product: string;
|
||||||
|
profitMargin: number;
|
||||||
|
revenue: number;
|
||||||
|
cost: number;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProfitAnalysis() {
|
||||||
|
const { data, isLoading } = useQuery<ProfitData>({
|
||||||
|
queryKey: ['profit-analysis'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await fetch(`${config.apiUrl}/analytics/profit`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch profit analysis');
|
||||||
|
}
|
||||||
|
const rawData = await response.json();
|
||||||
|
return {
|
||||||
|
byCategory: rawData.byCategory.map((item: any) => ({
|
||||||
|
...item,
|
||||||
|
profitMargin: Number(item.profitMargin) || 0,
|
||||||
|
revenue: Number(item.revenue) || 0,
|
||||||
|
cost: Number(item.cost) || 0
|
||||||
|
})),
|
||||||
|
overTime: rawData.overTime.map((item: any) => ({
|
||||||
|
...item,
|
||||||
|
profitMargin: Number(item.profitMargin) || 0,
|
||||||
|
revenue: Number(item.revenue) || 0,
|
||||||
|
cost: Number(item.cost) || 0
|
||||||
|
})),
|
||||||
|
topProducts: rawData.topProducts.map((item: any) => ({
|
||||||
|
...item,
|
||||||
|
profitMargin: Number(item.profitMargin) || 0,
|
||||||
|
revenue: Number(item.revenue) || 0,
|
||||||
|
cost: Number(item.cost) || 0
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading || !data) {
|
||||||
|
return <div>Loading profit analysis...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Profit Margins by Category</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<BarChart data={data.byCategory}>
|
||||||
|
<XAxis dataKey="category" />
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Profit Margin Trend</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<LineChart data={data.overTime}>
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
tickFormatter={(value) => new Date(value).toLocaleDateString()}
|
||||||
|
/>
|
||||||
|
<YAxis tickFormatter={(value) => `${value}%`} />
|
||||||
|
<Tooltip
|
||||||
|
labelFormatter={(label) => new Date(label).toLocaleDateString()}
|
||||||
|
formatter={(value: number) => [`${value.toFixed(1)}%`, 'Profit Margin']}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="profitMargin"
|
||||||
|
stroke="#4ade80"
|
||||||
|
name="Profit Margin"
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Top Performing Products by Profit Margin</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{data.topProducts.map((product) => (
|
||||||
|
<div key={product.product} className="flex items-center">
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium">{product.product}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Revenue: ${product.revenue.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="ml-4 text-right">
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{product.profitMargin.toFixed(1)}% margin
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Cost: ${product.cost.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
176
inventory/src/components/analytics/StockAnalysis.tsx
Normal file
176
inventory/src/components/analytics/StockAnalysis.tsx
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip, LineChart, Line } from 'recharts';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import config from '../../config';
|
||||||
|
|
||||||
|
interface StockData {
|
||||||
|
turnoverByCategory: {
|
||||||
|
category: string;
|
||||||
|
turnoverRate: number;
|
||||||
|
averageStock: number;
|
||||||
|
totalSales: number;
|
||||||
|
}[];
|
||||||
|
stockLevels: {
|
||||||
|
date: string;
|
||||||
|
inStock: number;
|
||||||
|
lowStock: number;
|
||||||
|
outOfStock: number;
|
||||||
|
}[];
|
||||||
|
criticalItems: {
|
||||||
|
product: string;
|
||||||
|
sku: string;
|
||||||
|
stockQuantity: number;
|
||||||
|
reorderPoint: number;
|
||||||
|
turnoverRate: number;
|
||||||
|
daysUntilStockout: number;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StockAnalysis() {
|
||||||
|
const { data, isLoading } = useQuery<StockData>({
|
||||||
|
queryKey: ['stock-analysis'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await fetch(`${config.apiUrl}/analytics/stock`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch stock analysis');
|
||||||
|
}
|
||||||
|
const rawData = await response.json();
|
||||||
|
return {
|
||||||
|
turnoverByCategory: rawData.turnoverByCategory.map((item: any) => ({
|
||||||
|
...item,
|
||||||
|
turnoverRate: Number(item.turnoverRate) || 0,
|
||||||
|
averageStock: Number(item.averageStock) || 0,
|
||||||
|
totalSales: Number(item.totalSales) || 0
|
||||||
|
})),
|
||||||
|
stockLevels: rawData.stockLevels.map((item: any) => ({
|
||||||
|
...item,
|
||||||
|
inStock: Number(item.inStock) || 0,
|
||||||
|
lowStock: Number(item.lowStock) || 0,
|
||||||
|
outOfStock: Number(item.outOfStock) || 0
|
||||||
|
})),
|
||||||
|
criticalItems: rawData.criticalItems.map((item: any) => ({
|
||||||
|
...item,
|
||||||
|
stockQuantity: Number(item.stockQuantity) || 0,
|
||||||
|
reorderPoint: Number(item.reorderPoint) || 0,
|
||||||
|
turnoverRate: Number(item.turnoverRate) || 0,
|
||||||
|
daysUntilStockout: Number(item.daysUntilStockout) || 0
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading || !data) {
|
||||||
|
return <div>Loading stock analysis...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStockStatus = (daysUntilStockout: number) => {
|
||||||
|
if (daysUntilStockout <= 7) {
|
||||||
|
return <Badge variant="destructive">Critical</Badge>;
|
||||||
|
}
|
||||||
|
if (daysUntilStockout <= 14) {
|
||||||
|
return <Badge variant="outline">Warning</Badge>;
|
||||||
|
}
|
||||||
|
return <Badge variant="secondary">OK</Badge>;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Stock Turnover by Category</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<BarChart data={data.turnoverByCategory}>
|
||||||
|
<XAxis dataKey="category" />
|
||||||
|
<YAxis tickFormatter={(value) => `${value.toFixed(1)}x`} />
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value: number) => [`${value.toFixed(1)}x`, 'Turnover Rate']}
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
dataKey="turnoverRate"
|
||||||
|
fill="#fbbf24"
|
||||||
|
name="Turnover Rate"
|
||||||
|
/>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Stock Level Trends</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<LineChart data={data.stockLevels}>
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
tickFormatter={(value) => new Date(value).toLocaleDateString()}
|
||||||
|
/>
|
||||||
|
<YAxis />
|
||||||
|
<Tooltip
|
||||||
|
labelFormatter={(label) => new Date(label).toLocaleDateString()}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="inStock"
|
||||||
|
stroke="#4ade80"
|
||||||
|
name="In Stock"
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="lowStock"
|
||||||
|
stroke="#fbbf24"
|
||||||
|
name="Low Stock"
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="outOfStock"
|
||||||
|
stroke="#f87171"
|
||||||
|
name="Out of Stock"
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Critical Stock Items</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{data.criticalItems.map((item) => (
|
||||||
|
<div key={item.sku} className="flex items-center">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="text-sm font-medium">{item.product}</p>
|
||||||
|
{getStockStatus(item.daysUntilStockout)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
SKU: {item.sku}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="ml-4 text-right space-y-1">
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{item.stockQuantity} in stock
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Reorder at: {item.reorderPoint}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{item.daysUntilStockout} days until stockout
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
155
inventory/src/components/analytics/VendorPerformance.tsx
Normal file
155
inventory/src/components/analytics/VendorPerformance.tsx
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip, ScatterChart, Scatter, ZAxis } from 'recharts';
|
||||||
|
import config from '../../config';
|
||||||
|
|
||||||
|
interface VendorData {
|
||||||
|
performance: {
|
||||||
|
vendor: string;
|
||||||
|
salesVolume: number;
|
||||||
|
profitMargin: number;
|
||||||
|
stockTurnover: number;
|
||||||
|
productCount: number;
|
||||||
|
}[];
|
||||||
|
comparison: {
|
||||||
|
vendor: string;
|
||||||
|
salesPerProduct: number;
|
||||||
|
averageMargin: number;
|
||||||
|
size: number;
|
||||||
|
}[];
|
||||||
|
trends: {
|
||||||
|
vendor: string;
|
||||||
|
month: string;
|
||||||
|
sales: number;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VendorPerformance() {
|
||||||
|
const { data, isLoading } = useQuery<VendorData>({
|
||||||
|
queryKey: ['vendor-performance'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await fetch(`${config.apiUrl}/analytics/vendors`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch vendor performance');
|
||||||
|
}
|
||||||
|
const rawData = await response.json();
|
||||||
|
return {
|
||||||
|
performance: rawData.performance.map((vendor: any) => ({
|
||||||
|
...vendor,
|
||||||
|
salesVolume: Number(vendor.salesVolume) || 0,
|
||||||
|
profitMargin: Number(vendor.profitMargin) || 0,
|
||||||
|
stockTurnover: Number(vendor.stockTurnover) || 0,
|
||||||
|
productCount: Number(vendor.productCount) || 0
|
||||||
|
})),
|
||||||
|
comparison: rawData.comparison.map((vendor: any) => ({
|
||||||
|
...vendor,
|
||||||
|
salesPerProduct: Number(vendor.salesPerProduct) || 0,
|
||||||
|
averageMargin: Number(vendor.averageMargin) || 0,
|
||||||
|
size: Number(vendor.size) || 0
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading || !data) {
|
||||||
|
return <div>Loading vendor performance...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Top Vendors by Sales Volume</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<BarChart data={data.performance}>
|
||||||
|
<XAxis dataKey="vendor" />
|
||||||
|
<YAxis tickFormatter={(value) => `$${(value / 1000).toFixed(0)}k`} />
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value: number) => [`$${value.toLocaleString()}`, 'Sales Volume']}
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
dataKey="salesVolume"
|
||||||
|
fill="#60a5fa"
|
||||||
|
name="Sales Volume"
|
||||||
|
/>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Vendor Performance Matrix</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<ScatterChart>
|
||||||
|
<XAxis
|
||||||
|
dataKey="salesPerProduct"
|
||||||
|
name="Sales per Product"
|
||||||
|
tickFormatter={(value) => `$${(value / 1000).toFixed(0)}k`}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
dataKey="averageMargin"
|
||||||
|
name="Average Margin"
|
||||||
|
tickFormatter={(value) => `${value.toFixed(0)}%`}
|
||||||
|
/>
|
||||||
|
<ZAxis
|
||||||
|
dataKey="size"
|
||||||
|
range={[50, 400]}
|
||||||
|
name="Product Count"
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value: number, name: string) => {
|
||||||
|
if (name === 'Sales per Product') return [`$${value.toLocaleString()}`, name];
|
||||||
|
if (name === 'Average Margin') return [`${value.toFixed(1)}%`, name];
|
||||||
|
return [value, name];
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Scatter
|
||||||
|
data={data.comparison}
|
||||||
|
fill="#60a5fa"
|
||||||
|
name="Vendors"
|
||||||
|
/>
|
||||||
|
</ScatterChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Vendor Performance Details</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{data.performance.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 space-y-1">
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
${vendor.salesVolume.toLocaleString()} sales
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{vendor.profitMargin.toFixed(1)}% margin
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{vendor.stockTurnover.toFixed(1)}x turnover
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -31,9 +31,9 @@ const items = [
|
|||||||
url: "/orders",
|
url: "/orders",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Reports",
|
title: "Analytics",
|
||||||
icon: BarChart2,
|
icon: BarChart2,
|
||||||
url: "/reports",
|
url: "/analytics",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
112
inventory/src/pages/Analytics.tsx
Normal file
112
inventory/src/pages/Analytics.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '../components/ui/card';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../components/ui/tabs';
|
||||||
|
import { ProfitAnalysis } from '../components/analytics/ProfitAnalysis';
|
||||||
|
import { VendorPerformance } from '../components/analytics/VendorPerformance';
|
||||||
|
import { StockAnalysis } from '../components/analytics/StockAnalysis';
|
||||||
|
import { PriceAnalysis } from '../components/analytics/PriceAnalysis';
|
||||||
|
import { CategoryPerformance } from '../components/analytics/CategoryPerformance';
|
||||||
|
import config from '../config';
|
||||||
|
|
||||||
|
interface AnalyticsStats {
|
||||||
|
profitMargin: number;
|
||||||
|
averageMarkup: number;
|
||||||
|
stockTurnoverRate: number;
|
||||||
|
vendorCount: number;
|
||||||
|
categoryCount: number;
|
||||||
|
averageOrderValue: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Analytics() {
|
||||||
|
const { data: stats, isLoading: statsLoading } = useQuery<AnalyticsStats>({
|
||||||
|
queryKey: ['analytics-stats'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await fetch(`${config.apiUrl}/analytics/stats`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch analytics stats');
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (statsLoading || !stats) {
|
||||||
|
return <div className="p-8">Loading analytics...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 space-y-4 p-8 pt-6">
|
||||||
|
<div className="flex items-center justify-between space-y-2">
|
||||||
|
<h2 className="text-3xl font-bold tracking-tight">Analytics</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">
|
||||||
|
Overall Profit Margin
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{stats.profitMargin.toFixed(1)}%
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">
|
||||||
|
Average Markup
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{stats.averageMarkup.toFixed(1)}%
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">
|
||||||
|
Stock Turnover Rate
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{stats.stockTurnoverRate.toFixed(2)}x
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs defaultValue="profit" className="space-y-4">
|
||||||
|
<TabsList className="grid w-full grid-cols-5 lg:w-[600px]">
|
||||||
|
<TabsTrigger value="profit">Profit</TabsTrigger>
|
||||||
|
<TabsTrigger value="vendors">Vendors</TabsTrigger>
|
||||||
|
<TabsTrigger value="stock">Stock</TabsTrigger>
|
||||||
|
<TabsTrigger value="pricing">Pricing</TabsTrigger>
|
||||||
|
<TabsTrigger value="categories">Categories</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="profit" className="space-y-4">
|
||||||
|
<ProfitAnalysis />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="vendors" className="space-y-4">
|
||||||
|
<VendorPerformance />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="stock" className="space-y-4">
|
||||||
|
<StockAnalysis />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="pricing" className="space-y-4">
|
||||||
|
<PriceAnalysis />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="categories" className="space-y-4">
|
||||||
|
<CategoryPerformance />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user