Add/update dashboard components
This commit is contained in:
@@ -6,27 +6,59 @@ router.get('/stats', async (req, res) => {
|
|||||||
const pool = req.app.locals.pool;
|
const pool = req.app.locals.pool;
|
||||||
try {
|
try {
|
||||||
const [stats] = await pool.query(`
|
const [stats] = await pool.query(`
|
||||||
|
WITH OrderStats AS (
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(*) as totalProducts,
|
COUNT(DISTINCT o.order_number) as total_orders,
|
||||||
COUNT(CASE WHEN stock_quantity <= 5 THEN 1 END) as lowStockProducts,
|
SUM(o.price * o.quantity) as total_revenue,
|
||||||
COALESCE(
|
AVG(subtotal) as average_order_value
|
||||||
(SELECT COUNT(DISTINCT order_number) FROM orders WHERE DATE(date) >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) AND canceled = false),
|
FROM orders o
|
||||||
0
|
LEFT JOIN (
|
||||||
) as totalOrders,
|
|
||||||
COALESCE(
|
|
||||||
(SELECT AVG(subtotal) FROM (
|
|
||||||
SELECT order_number, SUM(price * quantity) as subtotal
|
SELECT order_number, SUM(price * quantity) as subtotal
|
||||||
FROM orders
|
FROM orders
|
||||||
WHERE DATE(date) >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
WHERE DATE(date) >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||||
AND canceled = false
|
AND canceled = false
|
||||||
GROUP BY order_number
|
GROUP BY order_number
|
||||||
) t),
|
) t ON o.order_number = t.order_number
|
||||||
0
|
WHERE DATE(o.date) >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||||
) as averageOrderValue
|
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
|
FROM products
|
||||||
WHERE visible = true
|
WHERE visible = true
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
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) {
|
} catch (error) {
|
||||||
console.error('Error fetching dashboard stats:', error);
|
console.error('Error fetching dashboard stats:', error);
|
||||||
res.status(500).json({ error: 'Failed to fetch dashboard stats' });
|
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;
|
module.exports = router;
|
||||||
25
inventory/package-lock.json
generated
25
inventory/package-lock.json
generated
@@ -12,6 +12,7 @@
|
|||||||
"@radix-ui/react-collapsible": "^1.1.2",
|
"@radix-ui/react-collapsible": "^1.1.2",
|
||||||
"@radix-ui/react-dialog": "^1.1.4",
|
"@radix-ui/react-dialog": "^1.1.4",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.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-separator": "^1.1.1",
|
||||||
"@radix-ui/react-slot": "^1.1.1",
|
"@radix-ui/react-slot": "^1.1.1",
|
||||||
"@radix-ui/react-tabs": "^1.1.2",
|
"@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": {
|
"node_modules/@radix-ui/react-roving-focus": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.1.tgz",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"@radix-ui/react-collapsible": "^1.1.2",
|
"@radix-ui/react-collapsible": "^1.1.2",
|
||||||
"@radix-ui/react-dialog": "^1.1.4",
|
"@radix-ui/react-dialog": "^1.1.4",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.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-separator": "^1.1.1",
|
||||||
"@radix-ui/react-slot": "^1.1.1",
|
"@radix-ui/react-slot": "^1.1.1",
|
||||||
"@radix-ui/react-tabs": "^1.1.2",
|
"@radix-ui/react-tabs": "^1.1.2",
|
||||||
|
|||||||
@@ -1,124 +1,106 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis } from 'recharts';
|
import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis, Tooltip } from 'recharts';
|
||||||
import config from '../../config';
|
import config from '../../config';
|
||||||
|
|
||||||
interface CategoryStats {
|
interface InventoryMetrics {
|
||||||
categories: string;
|
stockLevels: {
|
||||||
count: number;
|
category: string;
|
||||||
}
|
|
||||||
|
|
||||||
interface StockLevels {
|
|
||||||
outOfStock: number;
|
|
||||||
lowStock: number;
|
|
||||||
inStock: number;
|
inStock: number;
|
||||||
overStock: number;
|
lowStock: number;
|
||||||
|
outOfStock: number;
|
||||||
|
}[];
|
||||||
|
topVendors: {
|
||||||
|
vendor: string;
|
||||||
|
productCount: number;
|
||||||
|
averageStockLevel: number;
|
||||||
|
}[];
|
||||||
|
stockTurnover: {
|
||||||
|
category: string;
|
||||||
|
rate: number;
|
||||||
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InventoryStats() {
|
export function InventoryStats() {
|
||||||
const { data: categoryStats, isLoading: categoryLoading, error: categoryError } = useQuery<CategoryStats[]>({
|
const { data, isLoading, error } = useQuery<InventoryMetrics>({
|
||||||
queryKey: ['category-stats'],
|
queryKey: ['inventory-metrics'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const response = await fetch(`${config.apiUrl}/dashboard/category-stats`);
|
const response = await fetch(`${config.apiUrl}/dashboard/inventory-metrics`);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to fetch category stats');
|
throw new Error('Failed to fetch inventory metrics');
|
||||||
}
|
}
|
||||||
return response.json();
|
return response.json();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: stockLevels, isLoading: stockLoading, error: stockError } = useQuery<StockLevels>({
|
if (isLoading) {
|
||||||
queryKey: ['stock-levels'],
|
return <div>Loading inventory metrics...</div>;
|
||||||
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 <div>Loading inventory stats...</div>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (categoryError || stockError) {
|
if (error) {
|
||||||
return <div className="text-red-500">Error loading inventory stats</div>;
|
return <div className="text-red-500">Error loading inventory metrics</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
|
<div className="grid gap-4">
|
||||||
<Card className="col-span-4">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Products by Category</CardTitle>
|
<CardTitle>Stock Levels by Category</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pl-2">
|
<CardContent>
|
||||||
<ResponsiveContainer width="100%" height={350}>
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
<BarChart data={categoryStats}>
|
<BarChart data={data?.stockLevels}>
|
||||||
<XAxis
|
<XAxis dataKey="category" />
|
||||||
dataKey="categories"
|
<YAxis />
|
||||||
stroke="#888888"
|
<Tooltip />
|
||||||
fontSize={12}
|
<Bar dataKey="inStock" name="In Stock" fill="#4ade80" />
|
||||||
tickLine={false}
|
<Bar dataKey="lowStock" name="Low Stock" fill="#fbbf24" />
|
||||||
axisLine={false}
|
<Bar dataKey="outOfStock" name="Out of Stock" fill="#f87171" />
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
stroke="#888888"
|
|
||||||
fontSize={12}
|
|
||||||
tickLine={false}
|
|
||||||
axisLine={false}
|
|
||||||
/>
|
|
||||||
<Bar
|
|
||||||
dataKey="count"
|
|
||||||
fill="hsl(var(--primary))"
|
|
||||||
radius={[4, 4, 0, 0]}
|
|
||||||
/>
|
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<div className="col-span-3 grid gap-4">
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader>
|
||||||
<CardTitle className="text-sm font-medium">
|
<CardTitle>Stock Turnover Rate</CardTitle>
|
||||||
Out of Stock
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">{stockLevels?.outOfStock}</div>
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
</CardContent>
|
<BarChart data={data?.stockTurnover}>
|
||||||
</Card>
|
<XAxis dataKey="category" />
|
||||||
<Card>
|
<YAxis />
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<Tooltip />
|
||||||
<CardTitle className="text-sm font-medium">
|
<Bar dataKey="rate" name="Turnover Rate" fill="#60a5fa" />
|
||||||
Low Stock
|
</BarChart>
|
||||||
</CardTitle>
|
</ResponsiveContainer>
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">{stockLevels?.lowStock}</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">
|
|
||||||
In Stock
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">{stockLevels?.inStock}</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">
|
|
||||||
Over Stock
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">{stockLevels?.overStock}</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Top Vendors</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{data?.topVendors.map((vendor) => (
|
||||||
|
<div key={vendor.vendor} className="flex items-center">
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium">{vendor.vendor}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{vendor.productCount} products
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="ml-4 text-right">
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
Avg. Stock: {vendor.averageStockLevel.toFixed(0)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
58
inventory/src/components/dashboard/SalesByCategory.tsx
Normal file
58
inventory/src/components/dashboard/SalesByCategory.tsx
Normal file
@@ -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<CategorySales[]>({
|
||||||
|
queryKey: ['sales-by-category'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await fetch(`${config.apiUrl}/dashboard/sales-by-category`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch category sales');
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <div>Loading chart...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <div className="text-red-500">Error loading category sales</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={data}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
labelLine={false}
|
||||||
|
outerRadius={80}
|
||||||
|
fill="#8884d8"
|
||||||
|
dataKey="total"
|
||||||
|
nameKey="category"
|
||||||
|
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
|
||||||
|
>
|
||||||
|
{data?.map((entry, index) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value: number) => [`$${value.toLocaleString()}`, 'Sales']}
|
||||||
|
/>
|
||||||
|
<Legend />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
71
inventory/src/components/dashboard/TrendingProducts.tsx
Normal file
71
inventory/src/components/dashboard/TrendingProducts.tsx
Normal file
@@ -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<TrendingProduct[]>({
|
||||||
|
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 <div>Loading trending products...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <div className="text-red-500">Error loading trending products</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{data?.map((product) => (
|
||||||
|
<Card key={product.product_id}>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center">
|
||||||
|
{product.image_url && (
|
||||||
|
<img
|
||||||
|
src={product.image_url}
|
||||||
|
alt={product.title}
|
||||||
|
className="h-12 w-12 rounded-md object-cover mr-4"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 space-y-1">
|
||||||
|
<p className="text-sm font-medium leading-none">{product.title}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">SKU: {product.sku}</p>
|
||||||
|
<div className="flex items-center pt-2">
|
||||||
|
<Progress value={Math.min(100, product.sales_growth)} className="h-2" />
|
||||||
|
<span className="ml-2 text-sm text-muted-foreground">
|
||||||
|
{product.sales_growth > 0 ? '+' : ''}{product.sales_growth}% growth
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="ml-4 text-right">
|
||||||
|
<p className="text-sm font-medium">${product.total_sales.toLocaleString()}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{product.stock_quantity} in stock
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
inventory/src/components/ui/progress.tsx
Normal file
26
inventory/src/components/ui/progress.tsx
Normal file
@@ -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<typeof ProgressPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
||||||
|
>(({ className, value, ...props }, ref) => (
|
||||||
|
<ProgressPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ProgressPrimitive.Indicator
|
||||||
|
className="h-full w-full flex-1 bg-primary transition-all"
|
||||||
|
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||||
|
/>
|
||||||
|
</ProgressPrimitive.Root>
|
||||||
|
))
|
||||||
|
Progress.displayName = ProgressPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Progress }
|
||||||
@@ -64,6 +64,7 @@ const SheetContent = React.forwardRef<
|
|||||||
className={cn(sheetVariants({ side }), className)}
|
className={cn(sheetVariants({ side }), className)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
|
<SheetPrimitive.Title className="sr-only">Sheet</SheetPrimitive.Title>
|
||||||
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">Close</span>
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|||||||
import { Overview } from '@/components/dashboard/Overview';
|
import { Overview } from '@/components/dashboard/Overview';
|
||||||
import { RecentSales } from '@/components/dashboard/RecentSales';
|
import { RecentSales } from '@/components/dashboard/RecentSales';
|
||||||
import { InventoryStats } from '@/components/dashboard/InventoryStats';
|
import { InventoryStats } from '@/components/dashboard/InventoryStats';
|
||||||
|
import { SalesByCategory } from '@/components/dashboard/SalesByCategory';
|
||||||
|
import { TrendingProducts } from '@/components/dashboard/TrendingProducts';
|
||||||
import config from '../config';
|
import config from '../config';
|
||||||
|
|
||||||
interface DashboardStats {
|
interface DashboardStats {
|
||||||
@@ -11,13 +13,8 @@ interface DashboardStats {
|
|||||||
lowStockProducts: number;
|
lowStockProducts: number;
|
||||||
totalOrders: number;
|
totalOrders: number;
|
||||||
averageOrderValue: number;
|
averageOrderValue: number;
|
||||||
}
|
totalRevenue: number;
|
||||||
|
profitMargin: number;
|
||||||
interface Order {
|
|
||||||
order_id: string;
|
|
||||||
customer_name: string;
|
|
||||||
total_amount: number;
|
|
||||||
order_date: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Dashboard() {
|
export function Dashboard() {
|
||||||
@@ -31,22 +28,13 @@ export function Dashboard() {
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
return {
|
return {
|
||||||
...data,
|
...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<Order[]>({
|
|
||||||
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) {
|
if (statsLoading) {
|
||||||
return <div className="p-8">Loading dashboard...</div>;
|
return <div className="p-8">Loading dashboard...</div>;
|
||||||
}
|
}
|
||||||
@@ -57,31 +45,22 @@ export function Dashboard() {
|
|||||||
<h2 className="text-3xl font-bold tracking-tight">Dashboard</h2>
|
<h2 className="text-3xl font-bold tracking-tight">Dashboard</h2>
|
||||||
</div>
|
</div>
|
||||||
<Tabs defaultValue="overview" className="space-y-4">
|
<Tabs defaultValue="overview" className="space-y-4">
|
||||||
<TabsList className="grid w-full grid-cols-3 lg:w-[400px]">
|
<TabsList className="grid w-full grid-cols-2 lg:w-[400px]">
|
||||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||||
<TabsTrigger value="inventory">Inventory</TabsTrigger>
|
<TabsTrigger value="inventory">Inventory</TabsTrigger>
|
||||||
<TabsTrigger value="orders">Orders</TabsTrigger>
|
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<TabsContent value="overview" className="space-y-4">
|
<TabsContent value="overview" className="space-y-4">
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium">
|
<CardTitle className="text-sm font-medium">
|
||||||
Total Products
|
Total Revenue
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">{stats?.totalProducts || 0}</div>
|
<div className="text-2xl font-bold">
|
||||||
</CardContent>
|
${(stats?.totalRevenue || 0).toLocaleString()}
|
||||||
</Card>
|
</div>
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">
|
|
||||||
Low Stock Products
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">{stats?.lowStockProducts || 0}</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
@@ -106,11 +85,23 @@ export function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">
|
||||||
|
Profit Margin
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{(stats?.profitMargin || 0).toFixed(1)}%
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
|
||||||
<Card className="col-span-4">
|
<Card className="col-span-4">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Overview</CardTitle>
|
<CardTitle>Sales Overview</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pl-2">
|
<CardContent className="pl-2">
|
||||||
<Overview />
|
<Overview />
|
||||||
@@ -125,49 +116,50 @@ export function Dashboard() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<TabsContent value="inventory" className="space-y-4">
|
|
||||||
<InventoryStats />
|
|
||||||
</TabsContent>
|
|
||||||
<TabsContent value="orders" className="space-y-4">
|
|
||||||
<div className="grid gap-4">
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Recent Orders</CardTitle>
|
<CardTitle>Sales by Category</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{ordersLoading ? (
|
<SalesByCategory />
|
||||||
<div>Loading orders...</div>
|
</CardContent>
|
||||||
) : orders && orders.length > 0 ? (
|
</Card>
|
||||||
<div className="space-y-8">
|
<Card>
|
||||||
{orders.map((order) => (
|
<CardHeader>
|
||||||
<div key={order.order_id} className="flex items-center">
|
<CardTitle>Trending Products</CardTitle>
|
||||||
<div className="ml-4 space-y-1">
|
</CardHeader>
|
||||||
<p className="text-sm font-medium leading-none">
|
<CardContent>
|
||||||
Order #{order.order_id}
|
<TrendingProducts />
|
||||||
</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{order.customer_name}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{new Date(order.order_date).toLocaleDateString()}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="ml-auto font-medium">
|
|
||||||
${order.total_amount.toFixed(2)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-center text-muted-foreground">
|
|
||||||
No recent orders found
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
<TabsContent value="inventory" className="space-y-4">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">
|
||||||
|
Total Products
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{stats?.totalProducts || 0}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">
|
||||||
|
Low Stock Products
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{stats?.lowStockProducts || 0}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
<InventoryStats />
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user