From 8d5173761cd96f81745f80cb60bccdda02886c7b Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 10 Jan 2025 19:13:02 -0500 Subject: [PATCH] Add analytics page --- inventory-server/src/routes/analytics.js | 379 ++++++++++++++++++ inventory-server/src/server.js | 35 +- inventory-server/src/utils/db.js | 21 + inventory/src/App.tsx | 2 + .../analytics/CategoryPerformance.tsx | 153 +++++++ .../components/analytics/PriceAnalysis.tsx | 182 +++++++++ .../components/analytics/ProfitAnalysis.tsx | 145 +++++++ .../components/analytics/StockAnalysis.tsx | 176 ++++++++ .../analytics/VendorPerformance.tsx | 155 +++++++ .../src/components/layout/AppSidebar.tsx | 4 +- inventory/src/pages/Analytics.tsx | 112 ++++++ 11 files changed, 1346 insertions(+), 18 deletions(-) create mode 100644 inventory-server/src/routes/analytics.js create mode 100644 inventory-server/src/utils/db.js create mode 100644 inventory/src/components/analytics/CategoryPerformance.tsx create mode 100644 inventory/src/components/analytics/PriceAnalysis.tsx create mode 100644 inventory/src/components/analytics/ProfitAnalysis.tsx create mode 100644 inventory/src/components/analytics/StockAnalysis.tsx create mode 100644 inventory/src/components/analytics/VendorPerformance.tsx create mode 100644 inventory/src/pages/Analytics.tsx diff --git a/inventory-server/src/routes/analytics.js b/inventory-server/src/routes/analytics.js new file mode 100644 index 0000000..05e3c83 --- /dev/null +++ b/inventory-server/src/routes/analytics.js @@ -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; \ No newline at end of file diff --git a/inventory-server/src/server.js b/inventory-server/src/server.js index a656178..fca6861 100755 --- a/inventory-server/src/server.js +++ b/inventory-server/src/server.js @@ -3,10 +3,12 @@ const fs = require('fs'); const express = require('express'); const mysql = require('mysql2/promise'); const { corsMiddleware, corsErrorHandler } = require('./middleware/cors'); +const { initPool } = require('./utils/db'); const productsRouter = require('./routes/products'); const dashboardRouter = require('./routes/dashboard'); const ordersRouter = require('./routes/orders'); const csvRouter = require('./routes/csv'); +const analyticsRouter = require('./routes/analytics'); // Get the absolute path to the .env file const envPath = path.resolve(process.cwd(), '.env'); @@ -55,11 +57,28 @@ app.use(corsMiddleware); app.use(express.json()); 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 app.use('/api/products', productsRouter); app.use('/api/dashboard', dashboardRouter); app.use('/api/orders', ordersRouter); app.use('/api/csv', csvRouter); +app.use('/api/analytics', analyticsRouter); // Basic health check route 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); }); -// 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 pool.getConnection() .then(connection => { diff --git a/inventory-server/src/utils/db.js b/inventory-server/src/utils/db.js new file mode 100644 index 0000000..28f689f --- /dev/null +++ b/inventory-server/src/utils/db.js @@ -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 +}; \ No newline at end of file diff --git a/inventory/src/App.tsx b/inventory/src/App.tsx index 5c0e24f..26e1c2e 100644 --- a/inventory/src/App.tsx +++ b/inventory/src/App.tsx @@ -6,6 +6,7 @@ import { Import } from './pages/Import'; import { Dashboard } from './pages/Dashboard'; import { Orders } from './pages/Orders'; import { Settings } from './pages/Settings'; +import { Analytics } from './pages/Analytics'; import { Toaster } from '@/components/ui/sonner'; const queryClient = new QueryClient(); @@ -21,6 +22,7 @@ function App() { } /> } /> } /> + } /> } /> diff --git a/inventory/src/components/analytics/CategoryPerformance.tsx b/inventory/src/components/analytics/CategoryPerformance.tsx new file mode 100644 index 0000000..60e4a4b --- /dev/null +++ b/inventory/src/components/analytics/CategoryPerformance.tsx @@ -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({ + 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
Loading category performance...
; + } + + 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 {value}; + }; + + return ( +
+
+ + + Category Revenue Distribution + + + + + entry.category} + > + {data.distribution.map((entry, index) => ( + + ))} + + [`$${value.toLocaleString()}`, 'Revenue']} + /> + + + + + + + + + Category Growth Rates + + + + + + `${value}%`} /> + [`${value.toFixed(1)}%`, 'Growth Rate']} + /> + + + + + +
+ + + + Category Performance Details + + +
+ {data.performance.map((category) => ( +
+
+

{category.category}

+

+ {category.productCount} products +

+
+
+

+ ${category.revenue.toLocaleString()} revenue +

+

+ ${category.profit.toLocaleString()} profit +

+

+ Growth: {formatGrowth(category.growth)} +

+
+
+ ))} +
+
+
+
+ ); +} \ No newline at end of file diff --git a/inventory/src/components/analytics/PriceAnalysis.tsx b/inventory/src/components/analytics/PriceAnalysis.tsx new file mode 100644 index 0000000..f2ec107 --- /dev/null +++ b/inventory/src/components/analytics/PriceAnalysis.tsx @@ -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({ + 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
Loading price analysis...
; + } + + return ( +
+
+ + + Price Point Analysis + + + + + `$${value}`} + /> + + + { + if (name === 'Price') return [`$${value}`, name]; + if (name === 'Sales Volume') return [value.toLocaleString(), name]; + if (name === 'Revenue') return [`$${value.toLocaleString()}`, name]; + return [value, name]; + }} + /> + + + + + + + + + Price Elasticity + + + + + new Date(value).toLocaleDateString()} + /> + + `$${value}`} + /> + new Date(label).toLocaleDateString()} + formatter={(value: number, name: string) => { + if (name === 'Price') return [`$${value}`, name]; + return [value.toLocaleString(), name]; + }} + /> + + + + + + +
+ + + + Price Optimization Recommendations + + +
+ {data.recommendations.map((item) => ( +
+
+

{item.product}

+

+ Current Price: ${item.currentPrice.toFixed(2)} +

+
+
+

+ Recommended: ${item.recommendedPrice.toFixed(2)} +

+

+ Potential Revenue: ${item.potentialRevenue.toLocaleString()} +

+

+ Confidence: {item.confidence}% +

+
+
+ ))} +
+
+
+
+ ); +} \ No newline at end of file diff --git a/inventory/src/components/analytics/ProfitAnalysis.tsx b/inventory/src/components/analytics/ProfitAnalysis.tsx new file mode 100644 index 0000000..5ea5464 --- /dev/null +++ b/inventory/src/components/analytics/ProfitAnalysis.tsx @@ -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({ + 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
Loading profit analysis...
; + } + + return ( +
+
+ + + Profit Margins by Category + + + + + + `${value}%`} /> + [`${value.toFixed(1)}%`, 'Profit Margin']} + /> + + + + + + + + + Profit Margin Trend + + + + + new Date(value).toLocaleDateString()} + /> + `${value}%`} /> + new Date(label).toLocaleDateString()} + formatter={(value: number) => [`${value.toFixed(1)}%`, 'Profit Margin']} + /> + + + + + +
+ + + + Top Performing Products by Profit Margin + + +
+ {data.topProducts.map((product) => ( +
+
+

{product.product}

+

+ Revenue: ${product.revenue.toLocaleString()} +

+
+
+

+ {product.profitMargin.toFixed(1)}% margin +

+

+ Cost: ${product.cost.toLocaleString()} +

+
+
+ ))} +
+
+
+
+ ); +} \ No newline at end of file diff --git a/inventory/src/components/analytics/StockAnalysis.tsx b/inventory/src/components/analytics/StockAnalysis.tsx new file mode 100644 index 0000000..a786390 --- /dev/null +++ b/inventory/src/components/analytics/StockAnalysis.tsx @@ -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({ + 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
Loading stock analysis...
; + } + + const getStockStatus = (daysUntilStockout: number) => { + if (daysUntilStockout <= 7) { + return Critical; + } + if (daysUntilStockout <= 14) { + return Warning; + } + return OK; + }; + + return ( +
+
+ + + Stock Turnover by Category + + + + + + `${value.toFixed(1)}x`} /> + [`${value.toFixed(1)}x`, 'Turnover Rate']} + /> + + + + + + + + + Stock Level Trends + + + + + new Date(value).toLocaleDateString()} + /> + + new Date(label).toLocaleDateString()} + /> + + + + + + + +
+ + + + Critical Stock Items + + +
+ {data.criticalItems.map((item) => ( +
+
+
+

{item.product}

+ {getStockStatus(item.daysUntilStockout)} +
+

+ SKU: {item.sku} +

+
+
+

+ {item.stockQuantity} in stock +

+

+ Reorder at: {item.reorderPoint} +

+

+ {item.daysUntilStockout} days until stockout +

+
+
+ ))} +
+
+
+
+ ); +} \ No newline at end of file diff --git a/inventory/src/components/analytics/VendorPerformance.tsx b/inventory/src/components/analytics/VendorPerformance.tsx new file mode 100644 index 0000000..dab7737 --- /dev/null +++ b/inventory/src/components/analytics/VendorPerformance.tsx @@ -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({ + 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
Loading vendor performance...
; + } + + return ( +
+
+ + + Top Vendors by Sales Volume + + + + + + `$${(value / 1000).toFixed(0)}k`} /> + [`$${value.toLocaleString()}`, 'Sales Volume']} + /> + + + + + + + + + Vendor Performance Matrix + + + + + `$${(value / 1000).toFixed(0)}k`} + /> + `${value.toFixed(0)}%`} + /> + + { + if (name === 'Sales per Product') return [`$${value.toLocaleString()}`, name]; + if (name === 'Average Margin') return [`${value.toFixed(1)}%`, name]; + return [value, name]; + }} + /> + + + + + +
+ + + + Vendor Performance Details + + +
+ {data.performance.map((vendor) => ( +
+
+

{vendor.vendor}

+

+ {vendor.productCount} products +

+
+
+

+ ${vendor.salesVolume.toLocaleString()} sales +

+

+ {vendor.profitMargin.toFixed(1)}% margin +

+

+ {vendor.stockTurnover.toFixed(1)}x turnover +

+
+
+ ))} +
+
+
+
+ ); +} \ No newline at end of file diff --git a/inventory/src/components/layout/AppSidebar.tsx b/inventory/src/components/layout/AppSidebar.tsx index 210276b..c67f1d0 100644 --- a/inventory/src/components/layout/AppSidebar.tsx +++ b/inventory/src/components/layout/AppSidebar.tsx @@ -31,9 +31,9 @@ const items = [ url: "/orders", }, { - title: "Reports", + title: "Analytics", icon: BarChart2, - url: "/reports", + url: "/analytics", }, ]; diff --git a/inventory/src/pages/Analytics.tsx b/inventory/src/pages/Analytics.tsx new file mode 100644 index 0000000..13baf16 --- /dev/null +++ b/inventory/src/pages/Analytics.tsx @@ -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({ + 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
Loading analytics...
; + } + + return ( +
+
+

Analytics

+
+ +
+ + + + Overall Profit Margin + + + +
+ {stats.profitMargin.toFixed(1)}% +
+
+
+ + + + Average Markup + + + +
+ {stats.averageMarkup.toFixed(1)}% +
+
+
+ + + + Stock Turnover Rate + + + +
+ {stats.stockTurnoverRate.toFixed(2)}x +
+
+
+
+ + + + Profit + Vendors + Stock + Pricing + Categories + + + + + + + + + + + + + + + + + + + + + + +
+ ); +} \ No newline at end of file