Rearrange dashboard to match IP

This commit is contained in:
2025-01-17 18:56:17 -05:00
parent 6b7a62ffaf
commit c412822881
5 changed files with 244 additions and 125 deletions

View File

@@ -26,18 +26,20 @@ router.get('/stock/metrics', async (req, res) => {
FROM products FROM products
`); `);
// Get brand values in a separate query // Get vendor stock values
const [brandValues] = await executeQuery(` const [vendorValues] = await executeQuery(`
SELECT SELECT
brand, vendor,
COALESCE(SUM(stock_quantity * price), 0) as value COUNT(DISTINCT product_id) as variant_count,
COALESCE(SUM(stock_quantity), 0) as stock_units,
COALESCE(SUM(stock_quantity * cost_price), 0) as stock_cost,
COALESCE(SUM(stock_quantity * price), 0) as stock_retail
FROM products FROM products
WHERE brand IS NOT NULL WHERE vendor IS NOT NULL
AND stock_quantity > 0 AND stock_quantity > 0
GROUP BY brand GROUP BY vendor
HAVING value > 0 HAVING stock_cost > 0
ORDER BY value DESC ORDER BY stock_cost DESC
LIMIT 8
`); `);
// Format the response with explicit type conversion // Format the response with explicit type conversion
@@ -47,9 +49,12 @@ router.get('/stock/metrics', async (req, res) => {
totalStockUnits: parseInt(stockMetrics.total_units) || 0, totalStockUnits: parseInt(stockMetrics.total_units) || 0,
totalStockCost: parseFloat(stockMetrics.total_cost) || 0, totalStockCost: parseFloat(stockMetrics.total_cost) || 0,
totalStockRetail: parseFloat(stockMetrics.total_retail) || 0, totalStockRetail: parseFloat(stockMetrics.total_retail) || 0,
brandRetailValue: brandValues.map(b => ({ vendorStock: vendorValues.map(v => ({
brand: b.brand, vendor: v.vendor,
value: parseFloat(b.value) || 0 variants: parseInt(v.variant_count) || 0,
units: parseInt(v.stock_units) || 0,
cost: parseFloat(v.stock_cost) || 0,
retail: parseFloat(v.stock_retail) || 0
})) }))
}; };
@@ -86,20 +91,19 @@ router.get('/purchase/metrics', async (req, res) => {
JOIN products p ON po.product_id = p.product_id JOIN products p ON po.product_id = p.product_id
`); `);
const [vendorValues] = await executeQuery(` const [vendorOrders] = await executeQuery(`
SELECT SELECT
po.vendor, po.vendor,
COALESCE(SUM(CASE COUNT(DISTINCT po.po_id) as order_count,
WHEN po.status = 'open' COALESCE(SUM(po.ordered), 0) as ordered_units,
THEN po.ordered * po.cost_price COALESCE(SUM(po.ordered * po.cost_price), 0) as order_cost,
ELSE 0 COALESCE(SUM(po.ordered * p.price), 0) as order_retail
END), 0) as value
FROM purchase_orders po FROM purchase_orders po
JOIN products p ON po.product_id = p.product_id
WHERE po.status = 'open' WHERE po.status = 'open'
GROUP BY po.vendor GROUP BY po.vendor
HAVING value > 0 HAVING order_cost > 0
ORDER BY value DESC ORDER BY order_cost DESC
LIMIT 8
`); `);
res.json({ res.json({
@@ -108,9 +112,12 @@ router.get('/purchase/metrics', async (req, res) => {
onOrderUnits: parseInt(poMetrics.total_units) || 0, onOrderUnits: parseInt(poMetrics.total_units) || 0,
onOrderCost: parseFloat(poMetrics.total_cost) || 0, onOrderCost: parseFloat(poMetrics.total_cost) || 0,
onOrderRetail: parseFloat(poMetrics.total_retail) || 0, onOrderRetail: parseFloat(poMetrics.total_retail) || 0,
vendorOrderValue: vendorValues.map(v => ({ vendorOrders: vendorOrders.map(v => ({
vendor: v.vendor, vendor: v.vendor,
value: parseFloat(v.value) || 0 orders: parseInt(v.order_count) || 0,
units: parseInt(v.ordered_units) || 0,
cost: parseFloat(v.order_cost) || 0,
retail: parseFloat(v.order_retail) || 0
})) }))
}); });
} catch (err) { } catch (err) {
@@ -150,52 +157,45 @@ router.get('/replenishment/metrics', async (req, res) => {
WHERE p.replenishable = true WHERE p.replenishable = true
`); `);
// Get category breakdown // Get top variants to replenish
const [categories] = await executeQuery(` const [variants] = await executeQuery(`
SELECT SELECT
c.name as category, p.product_id,
COUNT(DISTINCT CASE p.title,
WHEN pm.stock_status IN ('Critical', 'Reorder') p.stock_quantity as current_stock,
THEN p.product_id pm.reorder_qty as replenish_qty,
END) as products, (pm.reorder_qty * p.cost_price) as replenish_cost,
SUM(CASE (pm.reorder_qty * p.price) as replenish_retail,
WHEN pm.stock_status IN ('Critical', 'Reorder') pm.stock_status,
THEN pm.reorder_qty DATE_FORMAT(pm.planning_period_end, '%b %d, %Y') as planning_period
ELSE 0 FROM products p
END) as units,
SUM(CASE
WHEN pm.stock_status IN ('Critical', 'Reorder')
THEN pm.reorder_qty * p.cost_price
ELSE 0
END) as cost,
SUM(CASE
WHEN pm.stock_status IN ('Critical', 'Reorder')
THEN pm.reorder_qty * p.price
ELSE 0
END) as retail
FROM categories c
JOIN product_categories pc ON c.id = pc.category_id
JOIN products p ON pc.product_id = p.product_id
JOIN product_metrics pm ON p.product_id = pm.product_id JOIN product_metrics pm ON p.product_id = pm.product_id
WHERE p.replenishable = true WHERE p.replenishable = true
GROUP BY c.id, c.name AND pm.stock_status IN ('Critical', 'Reorder')
HAVING products > 0 ORDER BY
ORDER BY cost DESC CASE pm.stock_status
LIMIT 8 WHEN 'Critical' THEN 1
WHEN 'Reorder' THEN 2
END,
replenish_cost DESC
LIMIT 5
`); `);
// Format response // Format response
const response = { const response = {
productsToReplenish: parseInt(metrics.products_to_replenish) || 0, productsToReplenish: parseInt(metrics.products_to_replenish) || 0,
totalUnitsToReplenish: parseInt(metrics.total_units_needed) || 0, unitsToReplenish: parseInt(metrics.total_units_needed) || 0,
totalReplenishmentCost: parseFloat(metrics.total_cost) || 0, replenishmentCost: parseFloat(metrics.total_cost) || 0,
totalReplenishmentRetail: parseFloat(metrics.total_retail) || 0, replenishmentRetail: parseFloat(metrics.total_retail) || 0,
categoryData: categories.map(c => ({ topVariants: variants.map(v => ({
category: c.category, id: v.product_id,
products: parseInt(c.products) || 0, title: v.title,
units: parseInt(c.units) || 0, currentStock: parseInt(v.current_stock) || 0,
cost: parseFloat(c.cost) || 0, replenishQty: parseInt(v.replenish_qty) || 0,
retail: parseFloat(c.retail) || 0 replenishCost: parseFloat(v.replenish_cost) || 0,
replenishRetail: parseFloat(v.replenish_retail) || 0,
status: v.stock_status,
planningPeriod: v.planning_period
})) }))
}; };
@@ -833,4 +833,56 @@ router.get('/inventory-health', async (req, res) => {
} }
}); });
// GET /dashboard/replenish/products
// Returns top products that need replenishment
router.get('/replenish/products', async (req, res) => {
const limit = Math.max(1, Math.min(100, parseInt(req.query.limit) || 50));
try {
const [products] = await executeQuery(`
SELECT
p.product_id,
p.SKU,
p.title,
p.stock_quantity as current_stock,
pm.reorder_qty as replenish_qty,
(pm.reorder_qty * p.cost_price) as replenish_cost,
(pm.reorder_qty * p.price) as replenish_retail,
CASE
WHEN pm.daily_sales_avg > 0
THEN FLOOR(p.stock_quantity / pm.daily_sales_avg)
ELSE NULL
END as days_until_stockout
FROM products p
JOIN product_metrics pm ON p.product_id = pm.product_id
WHERE p.replenishable = true
AND pm.stock_status IN ('Critical', 'Reorder')
AND pm.reorder_qty > 0
ORDER BY
CASE pm.stock_status
WHEN 'Critical' THEN 1
WHEN 'Reorder' THEN 2
END,
replenish_cost DESC
LIMIT ?
`, [limit]);
// Format response
const response = products.map(p => ({
product_id: p.product_id,
SKU: p.SKU,
title: p.title,
current_stock: parseInt(p.current_stock) || 0,
replenish_qty: parseInt(p.replenish_qty) || 0,
replenish_cost: parseFloat(p.replenish_cost) || 0,
replenish_retail: parseFloat(p.replenish_retail) || 0,
days_until_stockout: p.days_until_stockout
}));
res.json(response);
} catch (err) {
console.error('Error fetching products to replenish:', err);
res.status(500).json({ error: 'Failed to fetch products to replenish' });
}
});
module.exports = router; module.exports = router;

View File

@@ -10,9 +10,12 @@ interface PurchaseMetricsData {
onOrderUnits: number onOrderUnits: number
onOrderCost: number onOrderCost: number
onOrderRetail: number onOrderRetail: number
vendorOrderValue: { vendorOrders: {
vendor: string vendor: string
value: number orders: number
units: number
cost: number
retail: number
}[] }[]
} }
@@ -42,28 +45,28 @@ export function PurchaseMetrics() {
return ( return (
<> <>
<CardHeader> <CardHeader>
<CardTitle className="text-lg font-medium">Purchase Orders Overview</CardTitle> <CardTitle className="text-lg font-medium">Purchase Overview</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="grid grid-cols-2 gap-4 mb-6"> <div className="grid grid-cols-2 gap-4 mb-6">
<div> <div>
<p className="text-sm font-medium text-muted-foreground">Active POs</p> <p className="text-sm font-medium text-muted-foreground">Active Orders</p>
<p className="text-2xl font-bold">{data?.activePurchaseOrders.toLocaleString() || 0}</p> <p className="text-2xl font-bold">{data?.activePurchaseOrders.toLocaleString() || 0}</p>
</div> </div>
<div> <div>
<p className="text-sm font-medium text-muted-foreground">Overdue POs</p> <p className="text-sm font-medium text-muted-foreground">Overdue Orders</p>
<p className="text-2xl font-bold text-destructive">{data?.overduePurchaseOrders.toLocaleString() || 0}</p> <p className="text-2xl font-bold">{data?.overduePurchaseOrders.toLocaleString() || 0}</p>
</div> </div>
<div> <div>
<p className="text-sm font-medium text-muted-foreground">On Order Units</p> <p className="text-sm font-medium text-muted-foreground">Units On Order</p>
<p className="text-2xl font-bold">{data?.onOrderUnits.toLocaleString() || 0}</p> <p className="text-2xl font-bold">{data?.onOrderUnits.toLocaleString() || 0}</p>
</div> </div>
<div> <div>
<p className="text-sm font-medium text-muted-foreground">On Order Cost</p> <p className="text-sm font-medium text-muted-foreground">Order Cost</p>
<p className="text-2xl font-bold">{formatCurrency(data?.onOrderCost || 0)}</p> <p className="text-2xl font-bold">{formatCurrency(data?.onOrderCost || 0)}</p>
</div> </div>
<div className="col-span-2"> <div className="col-span-2">
<p className="text-sm font-medium text-muted-foreground">On Order Retail</p> <p className="text-sm font-medium text-muted-foreground">Order Retail</p>
<p className="text-2xl font-bold">{formatCurrency(data?.onOrderRetail || 0)}</p> <p className="text-2xl font-bold">{formatCurrency(data?.onOrderRetail || 0)}</p>
</div> </div>
</div> </div>
@@ -72,8 +75,8 @@ export function PurchaseMetrics() {
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<PieChart> <PieChart>
<Pie <Pie
data={data?.vendorOrderValue || []} data={data?.vendorOrders || []}
dataKey="value" dataKey="cost"
nameKey="vendor" nameKey="vendor"
cx="50%" cx="50%"
cy="50%" cy="50%"
@@ -81,7 +84,7 @@ export function PurchaseMetrics() {
outerRadius={80} outerRadius={80}
paddingAngle={2} paddingAngle={2}
> >
{data?.vendorOrderValue.map((entry, index) => ( {data?.vendorOrders?.map((entry, index) => (
<Cell <Cell
key={entry.vendor} key={entry.vendor}
fill={COLORS[index % COLORS.length]} fill={COLORS[index % COLORS.length]}

View File

@@ -10,9 +10,12 @@ interface StockMetricsData {
totalStockUnits: number totalStockUnits: number
totalStockCost: number totalStockCost: number
totalStockRetail: number totalStockRetail: number
brandRetailValue: { vendorStock: {
brand: string vendor: string
value: number variants: number
units: number
cost: number
retail: number
}[] }[]
} }
@@ -72,25 +75,25 @@ export function StockMetrics() {
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<PieChart> <PieChart>
<Pie <Pie
data={data?.brandRetailValue || []} data={data?.vendorStock || []}
dataKey="value" dataKey="cost"
nameKey="brand" nameKey="vendor"
cx="50%" cx="50%"
cy="50%" cy="50%"
innerRadius={60} innerRadius={60}
outerRadius={80} outerRadius={80}
paddingAngle={2} paddingAngle={2}
> >
{data?.brandRetailValue.map((entry, index) => ( {data?.vendorStock?.map((entry, index) => (
<Cell <Cell
key={entry.brand} key={entry.vendor}
fill={COLORS[index % COLORS.length]} fill={COLORS[index % COLORS.length]}
/> />
))} ))}
</Pie> </Pie>
<Tooltip <Tooltip
formatter={(value: number) => formatCurrency(value)} formatter={(value: number) => formatCurrency(value)}
labelFormatter={(label: string) => `Brand: ${label}`} labelFormatter={(label: string) => `Vendor: ${label}`}
/> />
</PieChart> </PieChart>
</ResponsiveContainer> </ResponsiveContainer>

View File

@@ -0,0 +1,77 @@
import { useQuery } from "@tanstack/react-query"
import { CardHeader, CardTitle, CardContent } from "@/components/ui/card"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import config from "@/config"
import { formatCurrency } from "@/lib/utils"
interface ReplenishProduct {
product_id: number
SKU: string
title: string
current_stock: number
replenish_qty: number
replenish_cost: number
replenish_retail: number
days_until_stockout: number | null
}
export function TopReplenishProducts() {
const { data } = useQuery<ReplenishProduct[]>({
queryKey: ["top-replenish-products"],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/dashboard/replenish/products?limit=50`)
if (!response.ok) {
throw new Error("Failed to fetch products to replenish")
}
return response.json()
},
})
return (
<>
<CardHeader>
<CardTitle className="text-lg font-medium">Top Products to Replenish</CardTitle>
</CardHeader>
<CardContent>
<ScrollArea className="h-[400px] w-full">
<Table>
<TableHeader>
<TableRow>
<TableHead>Product</TableHead>
<TableHead className="text-right">Current</TableHead>
<TableHead className="text-right">Replenish</TableHead>
<TableHead className="text-right">Cost</TableHead>
<TableHead className="text-right">Days</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.map((product) => (
<TableRow key={product.product_id}>
<TableCell>
<div>
<p className="font-medium">{product.title}</p>
<p className="text-sm text-muted-foreground">{product.SKU}</p>
</div>
</TableCell>
<TableCell className="text-right">
{product.current_stock.toLocaleString()}
</TableCell>
<TableCell className="text-right">
{product.replenish_qty.toLocaleString()}
</TableCell>
<TableCell className="text-right">
{formatCurrency(product.replenish_cost)}
</TableCell>
<TableCell className="text-right">
{product.days_until_stockout ?? "N/A"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</ScrollArea>
</CardContent>
</>
)
}

View File

@@ -1,16 +1,12 @@
import { Card } from "@/components/ui/card" import { Card } from "@/components/ui/card"
import { InventoryHealthSummary } from "@/components/dashboard/InventoryHealthSummary"
import { LowStockAlerts } from "@/components/dashboard/LowStockAlerts"
import { TrendingProducts } from "@/components/dashboard/TrendingProducts"
import { VendorPerformance } from "@/components/dashboard/VendorPerformance"
import { KeyMetricsCharts } from "@/components/dashboard/KeyMetricsCharts"
import { StockMetrics } from "@/components/dashboard/StockMetrics" import { StockMetrics } from "@/components/dashboard/StockMetrics"
import { PurchaseMetrics } from "@/components/dashboard/PurchaseMetrics" import { PurchaseMetrics } from "@/components/dashboard/PurchaseMetrics"
import { ReplenishmentMetrics } from "@/components/dashboard/ReplenishmentMetrics" import { ReplenishmentMetrics } from "@/components/dashboard/ReplenishmentMetrics"
import { ForecastMetrics } from "@/components/dashboard/ForecastMetrics" import { TopReplenishProducts } from "@/components/dashboard/TopReplenishProducts"
import { OverstockMetrics } from "@/components/dashboard/OverstockMetrics" import { OverstockMetrics } from "@/components/dashboard/OverstockMetrics"
import { TopOverstockedProducts } from "@/components/dashboard/TopOverstockedProducts" import { TopOverstockedProducts } from "@/components/dashboard/TopOverstockedProducts"
import { BestSellers } from "@/components/dashboard/BestSellers" import { BestSellers } from "@/components/dashboard/BestSellers"
import { ForecastMetrics } from "@/components/dashboard/ForecastMetrics"
import { SalesMetrics } from "@/components/dashboard/SalesMetrics" import { SalesMetrics } from "@/components/dashboard/SalesMetrics"
import { motion } from "motion/react" import { motion } from "motion/react"
@@ -22,59 +18,47 @@ export function Dashboard() {
</div> </div>
{/* First row - Stock and Purchase metrics */} {/* First row - Stock and Purchase metrics */}
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 grid-cols-2">
<Card> <Card className="col-span-1">
<StockMetrics /> <StockMetrics />
</Card> </Card>
<Card> <Card className="col-span-1">
<PurchaseMetrics /> <PurchaseMetrics />
</Card> </Card>
</div> </div>
{/* Second row - Replenishment and Overstock */} {/* Second row - Replenishment section */}
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 grid-cols-3">
<Card className="col-span-2">
<TopReplenishProducts />
</Card>
<div className="col-span-1 grid gap-4 grid-rows-2">
<Card> <Card>
<ReplenishmentMetrics /> <ReplenishmentMetrics />
</Card> </Card>
<Card>
<OverstockMetrics />
</Card>
</div>
{/* Third row - Products to Replenish and Overstocked Products */}
<div className="grid gap-4 md:grid-cols-2">
<Card>
<LowStockAlerts />
</Card>
<Card>
<TopOverstockedProducts />
</Card>
</div>
{/* Fourth row - Sales and Forecast */}
<div className="grid gap-4 md:grid-cols-2">
<Card>
<SalesMetrics />
</Card>
<Card> <Card>
<ForecastMetrics /> <ForecastMetrics />
</Card> </Card>
</div> </div>
</div>
{/* Fifth row - Best Sellers */} {/* Third row - Overstock section */}
<div className="grid gap-4"> <div className="grid gap-4 grid-cols-3">
<Card> <Card className="col-span-1">
<BestSellers /> <OverstockMetrics />
</Card>
<Card className="col-span-2">
<TopOverstockedProducts />
</Card> </Card>
</div> </div>
{/* Sixth row - Vendor Performance and Trending Products */} {/* Fourth row - Best Sellers and Sales */}
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 grid-cols-3">
<Card> <Card className="col-span-2">
<VendorPerformance /> <BestSellers />
</Card> </Card>
<Card> <Card className="col-span-1">
<TrendingProducts /> <SalesMetrics />
</Card> </Card>
</div> </div>
</motion.div> </motion.div>