diff --git a/inventory-server/src/routes/dashboard.js b/inventory-server/src/routes/dashboard.js index 4cb8abc..70046ac 100644 --- a/inventory-server/src/routes/dashboard.js +++ b/inventory-server/src/routes/dashboard.js @@ -6,27 +6,59 @@ router.get('/stats', async (req, res) => { const pool = req.app.locals.pool; try { const [stats] = await pool.query(` + WITH OrderStats AS ( + SELECT + COUNT(DISTINCT o.order_number) as total_orders, + SUM(o.price * o.quantity) as total_revenue, + AVG(subtotal) as average_order_value + FROM orders o + LEFT JOIN ( + SELECT order_number, SUM(price * quantity) as subtotal + FROM orders + WHERE DATE(date) >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) + AND canceled = false + GROUP BY order_number + ) t ON o.order_number = t.order_number + WHERE DATE(o.date) >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) + AND o.canceled = false + ), + ProfitStats AS ( + SELECT + SUM((o.price - p.cost_price) * o.quantity) as total_profit, + SUM(o.price * o.quantity) as revenue + FROM orders o + JOIN products p ON o.product_id = p.product_id + WHERE DATE(o.date) >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) + AND o.canceled = false + ), + ProductStats AS ( + SELECT + COUNT(*) as total_products, + COUNT(CASE WHEN stock_quantity <= 5 THEN 1 END) as low_stock_products + FROM products + WHERE visible = true + ) SELECT - COUNT(*) as totalProducts, - COUNT(CASE WHEN stock_quantity <= 5 THEN 1 END) as lowStockProducts, - COALESCE( - (SELECT COUNT(DISTINCT order_number) FROM orders WHERE DATE(date) >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) AND canceled = false), - 0 - ) as totalOrders, - COALESCE( - (SELECT AVG(subtotal) FROM ( - SELECT order_number, SUM(price * quantity) as subtotal - FROM orders - WHERE DATE(date) >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) - AND canceled = false - GROUP BY order_number - ) t), - 0 - ) as averageOrderValue - FROM products - WHERE visible = true + ps.total_products, + ps.low_stock_products, + os.total_orders, + os.average_order_value, + os.total_revenue, + prs.total_profit, + CASE + WHEN prs.revenue > 0 THEN (prs.total_profit / prs.revenue) * 100 + ELSE 0 + END as profit_margin + FROM ProductStats ps + CROSS JOIN OrderStats os + CROSS JOIN ProfitStats prs `); - res.json(stats[0]); + res.json({ + ...stats[0], + averageOrderValue: parseFloat(stats[0].average_order_value) || 0, + totalRevenue: parseFloat(stats[0].total_revenue) || 0, + profitMargin: parseFloat(stats[0].profit_margin) || 0 + }); } catch (error) { console.error('Error fetching dashboard stats:', error); res.status(500).json({ error: 'Failed to fetch dashboard stats' }); @@ -126,4 +158,192 @@ router.get('/stock-levels', async (req, res) => { } }); +// Get sales by category +router.get('/sales-by-category', async (req, res) => { + const pool = req.app.locals.pool; + try { + const [rows] = await pool.query(` + SELECT + p.categories as category, + SUM(o.price * o.quantity) as total + FROM orders o + JOIN products p ON o.product_id = p.product_id + WHERE o.canceled = false + AND DATE(o.date) >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) + GROUP BY p.categories + ORDER BY total DESC + LIMIT 6 + `); + + const total = rows.reduce((sum, row) => sum + parseFloat(row.total || 0), 0); + + res.json(rows.map(row => ({ + category: row.category || 'Uncategorized', + total: parseFloat(row.total || 0), + percentage: total > 0 ? (parseFloat(row.total || 0) / total) : 0 + }))); + } catch (error) { + console.error('Error fetching sales by category:', error); + res.status(500).json({ error: 'Failed to fetch sales by category' }); + } +}); + +// Get trending products +router.get('/trending-products', async (req, res) => { + const pool = req.app.locals.pool; + try { + const [rows] = await pool.query(` + WITH CurrentSales AS ( + SELECT + p.product_id, + p.title, + p.sku, + p.stock_quantity, + p.image, + COALESCE(SUM(o.price * o.quantity), 0) as total_sales + FROM products p + LEFT JOIN orders o ON p.product_id = o.product_id + AND o.canceled = false + AND DATE(o.date) >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) + WHERE p.visible = true + GROUP BY p.product_id, p.title, p.sku, p.stock_quantity, p.image + HAVING total_sales > 0 + ), + PreviousSales AS ( + SELECT + p.product_id, + COALESCE(SUM(o.price * o.quantity), 0) as previous_sales + FROM products p + LEFT JOIN orders o ON p.product_id = o.product_id + AND o.canceled = false + AND DATE(o.date) BETWEEN DATE_SUB(CURDATE(), INTERVAL 60 DAY) AND DATE_SUB(CURDATE(), INTERVAL 30 DAY) + WHERE p.visible = true + GROUP BY p.product_id + ) + SELECT + cs.*, + CASE + WHEN COALESCE(ps.previous_sales, 0) = 0 THEN + CASE WHEN cs.total_sales > 0 THEN 100 ELSE 0 END + ELSE ((cs.total_sales - ps.previous_sales) / ps.previous_sales * 100) + END as sales_growth + FROM CurrentSales cs + LEFT JOIN PreviousSales ps ON cs.product_id = ps.product_id + ORDER BY cs.total_sales DESC + LIMIT 5 + `); + + console.log('Trending products query result:', rows); + + res.json(rows.map(row => ({ + product_id: row.product_id, + title: row.title, + sku: row.sku, + total_sales: parseFloat(row.total_sales || 0), + sales_growth: parseFloat(row.sales_growth || 0), + stock_quantity: parseInt(row.stock_quantity || 0), + image_url: row.image || null + }))); + } catch (error) { + console.error('Error in trending products:', { + message: error.message, + stack: error.stack, + code: error.code, + sqlState: error.sqlState, + sqlMessage: error.sqlMessage + }); + res.status(500).json({ + error: 'Failed to fetch trending products', + details: error.message + }); + } +}); + +// Get inventory metrics +router.get('/inventory-metrics', async (req, res) => { + const pool = req.app.locals.pool; + try { + // Get stock levels by category + const [stockLevels] = await pool.query(` + SELECT + categories as category, + SUM(CASE WHEN stock_quantity > 5 THEN 1 ELSE 0 END) as inStock, + SUM(CASE WHEN stock_quantity > 0 AND stock_quantity <= 5 THEN 1 ELSE 0 END) as lowStock, + SUM(CASE WHEN stock_quantity = 0 THEN 1 ELSE 0 END) as outOfStock + FROM products + WHERE visible = true + GROUP BY categories + ORDER BY categories ASC + `); + + // Get top vendors with product counts and average stock + const [topVendors] = await pool.query(` + SELECT + vendor, + COUNT(*) as productCount, + AVG(stock_quantity) as averageStockLevel + FROM products + WHERE visible = true + AND vendor IS NOT NULL + AND vendor != '' + GROUP BY vendor + ORDER BY productCount DESC + LIMIT 5 + `); + + // Calculate stock turnover rate by category + // Turnover = Units sold in last 30 days / Average inventory level + const [stockTurnover] = await pool.query(` + WITH CategorySales AS ( + SELECT + p.categories as category, + SUM(o.quantity) as units_sold + FROM products p + LEFT JOIN orders o ON p.product_id = o.product_id + WHERE o.canceled = false + AND DATE(o.date) >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) + GROUP BY p.categories + ), + CategoryStock AS ( + SELECT + categories as category, + AVG(stock_quantity) as avg_stock + FROM products + WHERE visible = true + GROUP BY categories + ) + SELECT + cs.category, + CASE + WHEN cst.avg_stock > 0 THEN (cs.units_sold / cst.avg_stock) + ELSE 0 + END as rate + FROM CategorySales cs + JOIN CategoryStock cst ON cs.category = cst.category + ORDER BY rate DESC + `); + + res.json({ + stockLevels: stockLevels.map(row => ({ + ...row, + inStock: parseInt(row.inStock || 0), + lowStock: parseInt(row.lowStock || 0), + outOfStock: parseInt(row.outOfStock || 0) + })), + topVendors: topVendors.map(row => ({ + vendor: row.vendor, + productCount: parseInt(row.productCount || 0), + averageStockLevel: parseFloat(row.averageStockLevel || 0) + })), + stockTurnover: stockTurnover.map(row => ({ + category: row.category, + rate: parseFloat(row.rate || 0) + })) + }); + } catch (error) { + console.error('Error fetching inventory metrics:', error); + res.status(500).json({ error: 'Failed to fetch inventory metrics' }); + } +}); + module.exports = router; \ No newline at end of file diff --git a/inventory/package-lock.json b/inventory/package-lock.json index e195b39..b68e209 100644 --- a/inventory/package-lock.json +++ b/inventory/package-lock.json @@ -12,6 +12,7 @@ "@radix-ui/react-collapsible": "^1.1.2", "@radix-ui/react-dialog": "^1.1.4", "@radix-ui/react-dropdown-menu": "^2.1.4", + "@radix-ui/react-progress": "^1.1.1", "@radix-ui/react-separator": "^1.1.1", "@radix-ui/react-slot": "^1.1.1", "@radix-ui/react-tabs": "^1.1.2", @@ -1596,6 +1597,30 @@ } } }, + "node_modules/@radix-ui/react-progress": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.1.tgz", + "integrity": "sha512-6diOawA84f/eMxFHcWut0aE1C2kyE9dOyCTQOMRR2C/qPiXz/X0SaiA/RLbapQaXUCmy0/hLMf9meSccD1N0pA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-roving-focus": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.1.tgz", diff --git a/inventory/package.json b/inventory/package.json index 59e9610..85733a7 100644 --- a/inventory/package.json +++ b/inventory/package.json @@ -14,6 +14,7 @@ "@radix-ui/react-collapsible": "^1.1.2", "@radix-ui/react-dialog": "^1.1.4", "@radix-ui/react-dropdown-menu": "^2.1.4", + "@radix-ui/react-progress": "^1.1.1", "@radix-ui/react-separator": "^1.1.1", "@radix-ui/react-slot": "^1.1.1", "@radix-ui/react-tabs": "^1.1.2", diff --git a/inventory/src/components/dashboard/InventoryStats.tsx b/inventory/src/components/dashboard/InventoryStats.tsx index fd1e5ec..49d5645 100644 --- a/inventory/src/components/dashboard/InventoryStats.tsx +++ b/inventory/src/components/dashboard/InventoryStats.tsx @@ -1,124 +1,106 @@ import { useQuery } from '@tanstack/react-query'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis } from 'recharts'; +import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis, Tooltip } from 'recharts'; import config from '../../config'; -interface CategoryStats { - categories: string; - count: number; -} - -interface StockLevels { - outOfStock: number; - lowStock: number; - inStock: number; - overStock: number; +interface InventoryMetrics { + stockLevels: { + category: string; + inStock: number; + lowStock: number; + outOfStock: number; + }[]; + topVendors: { + vendor: string; + productCount: number; + averageStockLevel: number; + }[]; + stockTurnover: { + category: string; + rate: number; + }[]; } export function InventoryStats() { - const { data: categoryStats, isLoading: categoryLoading, error: categoryError } = useQuery({ - queryKey: ['category-stats'], + const { data, isLoading, error } = useQuery({ + queryKey: ['inventory-metrics'], queryFn: async () => { - const response = await fetch(`${config.apiUrl}/dashboard/category-stats`); + const response = await fetch(`${config.apiUrl}/dashboard/inventory-metrics`); if (!response.ok) { - throw new Error('Failed to fetch category stats'); + throw new Error('Failed to fetch inventory metrics'); } return response.json(); }, }); - const { data: stockLevels, isLoading: stockLoading, error: stockError } = useQuery({ - queryKey: ['stock-levels'], - queryFn: async () => { - const response = await fetch(`${config.apiUrl}/dashboard/stock-levels`); - if (!response.ok) { - throw new Error('Failed to fetch stock levels'); - } - return response.json(); - }, - }); - - if (categoryLoading || stockLoading) { - return
Loading inventory stats...
; + if (isLoading) { + return
Loading inventory metrics...
; } - if (categoryError || stockError) { - return
Error loading inventory stats
; + if (error) { + return
Error loading inventory metrics
; } return ( -
- - - Products by Category - - - - - - - - - - - -
+
+
- - - Out of Stock - + + Stock Levels by Category -
{stockLevels?.outOfStock}
+ + + + + + + + + +
- - - Low Stock - + + Stock Turnover Rate -
{stockLevels?.lowStock}
-
-
- - - - In Stock - - - -
{stockLevels?.inStock}
-
-
- - - - Over Stock - - - -
{stockLevels?.overStock}
+ + + + + + + +
+ + + Top Vendors + + +
+ {data?.topVendors.map((vendor) => ( +
+
+

{vendor.vendor}

+

+ {vendor.productCount} products +

+
+
+

+ Avg. Stock: {vendor.averageStockLevel.toFixed(0)} +

+
+
+ ))} +
+
+
); } \ No newline at end of file diff --git a/inventory/src/components/dashboard/SalesByCategory.tsx b/inventory/src/components/dashboard/SalesByCategory.tsx new file mode 100644 index 0000000..286428c --- /dev/null +++ b/inventory/src/components/dashboard/SalesByCategory.tsx @@ -0,0 +1,58 @@ +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({ + 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
Loading chart...
; + } + + if (error) { + return
Error loading category sales
; + } + + return ( + + + `${name} ${(percent * 100).toFixed(0)}%`} + > + {data?.map((entry, index) => ( + + ))} + + [`$${value.toLocaleString()}`, 'Sales']} + /> + + + + ); +} \ No newline at end of file diff --git a/inventory/src/components/dashboard/TrendingProducts.tsx b/inventory/src/components/dashboard/TrendingProducts.tsx new file mode 100644 index 0000000..dd18ec6 --- /dev/null +++ b/inventory/src/components/dashboard/TrendingProducts.tsx @@ -0,0 +1,71 @@ +import { useQuery } from '@tanstack/react-query'; +import { Card, CardContent } from '@/components/ui/card'; +import { Progress } from '@/components/ui/progress'; +import config from '../../config'; + +interface TrendingProduct { + product_id: string; + title: string; + sku: string; + total_sales: number; + sales_growth: number; + stock_quantity: number; + image_url: string; +} + +export function TrendingProducts() { + const { data, isLoading, error } = useQuery({ + queryKey: ['trending-products'], + queryFn: async () => { + const response = await fetch(`${config.apiUrl}/dashboard/trending-products`); + if (!response.ok) { + throw new Error('Failed to fetch trending products'); + } + return response.json(); + }, + }); + + if (isLoading) { + return
Loading trending products...
; + } + + if (error) { + return
Error loading trending products
; + } + + return ( +
+ {data?.map((product) => ( + + +
+ {product.image_url && ( + {product.title} + )} +
+

{product.title}

+

SKU: {product.sku}

+
+ + + {product.sales_growth > 0 ? '+' : ''}{product.sales_growth}% growth + +
+
+
+

${product.total_sales.toLocaleString()}

+

+ {product.stock_quantity} in stock +

+
+
+
+
+ ))} +
+ ); +} \ No newline at end of file diff --git a/inventory/src/components/ui/progress.tsx b/inventory/src/components/ui/progress.tsx new file mode 100644 index 0000000..3fd47ad --- /dev/null +++ b/inventory/src/components/ui/progress.tsx @@ -0,0 +1,26 @@ +import * as React from "react" +import * as ProgressPrimitive from "@radix-ui/react-progress" + +import { cn } from "@/lib/utils" + +const Progress = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, value, ...props }, ref) => ( + + + +)) +Progress.displayName = ProgressPrimitive.Root.displayName + +export { Progress } diff --git a/inventory/src/components/ui/sheet.tsx b/inventory/src/components/ui/sheet.tsx index 272cb72..eef1802 100644 --- a/inventory/src/components/ui/sheet.tsx +++ b/inventory/src/components/ui/sheet.tsx @@ -64,6 +64,7 @@ const SheetContent = React.forwardRef< className={cn(sheetVariants({ side }), className)} {...props} > + Sheet Close diff --git a/inventory/src/pages/Dashboard.tsx b/inventory/src/pages/Dashboard.tsx index c1f23dc..130bacb 100644 --- a/inventory/src/pages/Dashboard.tsx +++ b/inventory/src/pages/Dashboard.tsx @@ -4,6 +4,8 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Overview } from '@/components/dashboard/Overview'; import { RecentSales } from '@/components/dashboard/RecentSales'; import { InventoryStats } from '@/components/dashboard/InventoryStats'; +import { SalesByCategory } from '@/components/dashboard/SalesByCategory'; +import { TrendingProducts } from '@/components/dashboard/TrendingProducts'; import config from '../config'; interface DashboardStats { @@ -11,13 +13,8 @@ interface DashboardStats { lowStockProducts: number; totalOrders: number; averageOrderValue: number; -} - -interface Order { - order_id: string; - customer_name: string; - total_amount: number; - order_date: string; + totalRevenue: number; + profitMargin: number; } export function Dashboard() { @@ -31,22 +28,13 @@ export function Dashboard() { const data = await response.json(); return { ...data, - averageOrderValue: parseFloat(data.averageOrderValue) || 0 + averageOrderValue: parseFloat(data.averageOrderValue) || 0, + totalRevenue: parseFloat(data.totalRevenue) || 0, + profitMargin: parseFloat(data.profitMargin) || 0 }; }, }); - const { data: orders, isLoading: ordersLoading } = useQuery({ - 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'); - } - return response.json(); - }, - }); - if (statsLoading) { return
Loading dashboard...
; } @@ -57,31 +45,22 @@ export function Dashboard() {

Dashboard

- + Overview Inventory - Orders
- Total Products + Total Revenue -
{stats?.totalProducts || 0}
-
-
- - - - Low Stock Products - - - -
{stats?.lowStockProducts || 0}
+
+ ${(stats?.totalRevenue || 0).toLocaleString()} +
@@ -106,11 +85,23 @@ export function Dashboard() {
+ + + + Profit Margin + + + +
+ {(stats?.profitMargin || 0).toFixed(1)}% +
+
+
- Overview + Sales Overview @@ -125,49 +116,50 @@ export function Dashboard() {
- - - - - -
+
- Recent Orders + Sales by Category - {ordersLoading ? ( -
Loading orders...
- ) : orders && orders.length > 0 ? ( -
- {orders.map((order) => ( -
-
-

- Order #{order.order_id} -

-

- {order.customer_name} -

-

- {new Date(order.order_date).toLocaleDateString()} -

-
-
- ${order.total_amount.toFixed(2)} -
-
- ))} -
- ) : ( -
- No recent orders found -
- )} + +
+
+ + + Trending Products + + +
+ +
+ + + + Total Products + + + +
{stats?.totalProducts || 0}
+
+
+ + + + Low Stock Products + + + +
{stats?.lowStockProducts || 0}
+
+
+
+ +
);