Add new dashboard backend
This commit is contained in:
@@ -1,117 +1,77 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Pie } from "react-chartjs-2"
|
||||
import { Chart as ChartJS, ArcElement, Tooltip, Legend } from 'chart.js'
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { AlertCircle, AlertTriangle, CheckCircle2, PackageSearch } from "lucide-react"
|
||||
import config from "@/config"
|
||||
|
||||
ChartJS.register(ArcElement, Tooltip, Legend)
|
||||
|
||||
interface InventoryHealthData {
|
||||
interface InventoryHealth {
|
||||
critical: number
|
||||
reorder: number
|
||||
healthy: number
|
||||
overstock: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export function InventoryHealthSummary() {
|
||||
const { data, isLoading } = useQuery<InventoryHealthData>({
|
||||
queryKey: ['inventoryHealth'],
|
||||
const { data: summary } = useQuery<InventoryHealth>({
|
||||
queryKey: ["inventory-health"],
|
||||
queryFn: async () => {
|
||||
const response = await fetch('/api/inventory/health/summary')
|
||||
const response = await fetch(`${config.apiUrl}/dashboard/inventory/health/summary`)
|
||||
if (!response.ok) {
|
||||
throw new Error('Network response was not ok')
|
||||
throw new Error("Failed to fetch inventory health")
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const chartData = {
|
||||
labels: ['Critical', 'Reorder', 'Healthy', 'Overstock'],
|
||||
datasets: [
|
||||
{
|
||||
data: [
|
||||
data?.critical || 0,
|
||||
data?.reorder || 0,
|
||||
data?.healthy || 0,
|
||||
data?.overstock || 0
|
||||
],
|
||||
backgroundColor: [
|
||||
'rgb(239, 68, 68)', // red-500
|
||||
'rgb(234, 179, 8)', // yellow-500
|
||||
'rgb(34, 197, 94)', // green-500
|
||||
'rgb(59, 130, 246)', // blue-500
|
||||
],
|
||||
borderColor: [
|
||||
'rgb(239, 68, 68, 0.2)',
|
||||
'rgb(234, 179, 8, 0.2)',
|
||||
'rgb(34, 197, 94, 0.2)',
|
||||
'rgb(59, 130, 246, 0.2)',
|
||||
],
|
||||
borderWidth: 1,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const options = {
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'right' as const,
|
||||
},
|
||||
const stats = [
|
||||
{
|
||||
title: "Critical Stock",
|
||||
value: summary?.critical || 0,
|
||||
description: "Products needing immediate attention",
|
||||
icon: AlertCircle,
|
||||
className: "bg-destructive/10",
|
||||
iconClassName: "text-destructive",
|
||||
},
|
||||
}
|
||||
|
||||
const total = data ? data.critical + data.reorder + data.healthy + data.overstock : 0
|
||||
{
|
||||
title: "Reorder Soon",
|
||||
value: summary?.reorder || 0,
|
||||
description: "Products approaching reorder point",
|
||||
icon: AlertTriangle,
|
||||
className: "bg-warning/10",
|
||||
iconClassName: "text-warning",
|
||||
},
|
||||
{
|
||||
title: "Healthy Stock",
|
||||
value: summary?.healthy || 0,
|
||||
description: "Products at optimal levels",
|
||||
icon: CheckCircle2,
|
||||
className: "bg-success/10",
|
||||
iconClassName: "text-success",
|
||||
},
|
||||
{
|
||||
title: "Overstock",
|
||||
value: summary?.overstock || 0,
|
||||
description: "Products exceeding optimal levels",
|
||||
icon: PackageSearch,
|
||||
className: "bg-muted",
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<Card className="col-span-4">
|
||||
<CardHeader>
|
||||
<CardTitle>Inventory Health</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="h-[200px] flex items-center justify-center">
|
||||
{!isLoading && <Pie data={chartData} options={options} />}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">Critical</span>
|
||||
<span className="text-2xl font-bold text-red-500">
|
||||
{data?.critical || 0}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{total ? Math.round((data?.critical / total) * 100) : 0}% of total
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">Reorder</span>
|
||||
<span className="text-2xl font-bold text-yellow-500">
|
||||
{data?.reorder || 0}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{total ? Math.round((data?.reorder / total) * 100) : 0}% of total
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">Healthy</span>
|
||||
<span className="text-2xl font-bold text-green-500">
|
||||
{data?.healthy || 0}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{total ? Math.round((data?.healthy / total) * 100) : 0}% of total
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">Overstock</span>
|
||||
<span className="text-2xl font-bold text-blue-500">
|
||||
{data?.overstock || 0}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{total ? Math.round((data?.overstock / total) * 100) : 0}% of total
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<>
|
||||
{stats.map((stat) => (
|
||||
<Card key={stat.title} className={stat.className}>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium">{stat.title}</CardTitle>
|
||||
<stat.icon className={`h-4 w-4 ${stat.iconClassName}`} />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stat.value}</div>
|
||||
<p className="text-xs text-muted-foreground">{stat.description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,135 +1,232 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { Line } from "react-chartjs-2"
|
||||
import { CardHeader, CardTitle, CardContent } from "@/components/ui/card"
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Area,
|
||||
AreaChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
Legend,
|
||||
TimeScale
|
||||
} from 'chart.js'
|
||||
import 'chartjs-adapter-date-fns'
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import config from "@/config"
|
||||
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
TimeScale
|
||||
)
|
||||
|
||||
interface TimeSeriesData {
|
||||
interface MetricDataPoint {
|
||||
date: string
|
||||
revenue: number
|
||||
cost: number
|
||||
inventory_value: number
|
||||
value: number
|
||||
}
|
||||
|
||||
interface KeyMetrics {
|
||||
revenue: MetricDataPoint[]
|
||||
inventory_value: MetricDataPoint[]
|
||||
gmroi: MetricDataPoint[]
|
||||
}
|
||||
|
||||
export function KeyMetricsCharts() {
|
||||
const { data, isLoading } = useQuery<TimeSeriesData[]>({
|
||||
queryKey: ['keyMetrics'],
|
||||
const { data: metrics } = useQuery<KeyMetrics>({
|
||||
queryKey: ["key-metrics"],
|
||||
queryFn: async () => {
|
||||
const response = await fetch('/api/metrics/timeseries')
|
||||
const response = await fetch(`${config.apiUrl}/metrics/trends`)
|
||||
if (!response.ok) {
|
||||
throw new Error('Network response was not ok')
|
||||
throw new Error("Failed to fetch metrics trends")
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const revenueVsCostData = {
|
||||
labels: data?.map(d => d.date),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Revenue',
|
||||
data: data?.map(d => d.revenue),
|
||||
borderColor: 'rgb(34, 197, 94)', // green-500
|
||||
backgroundColor: 'rgba(34, 197, 94, 0.5)',
|
||||
tension: 0.3,
|
||||
},
|
||||
{
|
||||
label: 'Cost',
|
||||
data: data?.map(d => d.cost),
|
||||
borderColor: 'rgb(239, 68, 68)', // red-500
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.5)',
|
||||
tension: 0.3,
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const inventoryValueData = {
|
||||
labels: data?.map(d => d.date),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Inventory Value',
|
||||
data: data?.map(d => d.inventory_value),
|
||||
borderColor: 'rgb(59, 130, 246)', // blue-500
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.5)',
|
||||
tension: 0.3,
|
||||
fill: true,
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const options = {
|
||||
responsive: true,
|
||||
interaction: {
|
||||
mode: 'index' as const,
|
||||
intersect: false,
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: 'time' as const,
|
||||
time: {
|
||||
unit: 'month' as const,
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Date'
|
||||
}
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Amount ($)'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const formatCurrency = (value: number) =>
|
||||
new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value)
|
||||
|
||||
return (
|
||||
<Card className="col-span-8">
|
||||
<>
|
||||
<CardHeader>
|
||||
<CardTitle>Key Financial Metrics</CardTitle>
|
||||
<CardTitle className="text-lg font-medium">Key Metrics</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4">
|
||||
<div className="h-[200px]">
|
||||
{!isLoading && (
|
||||
<Line
|
||||
data={revenueVsCostData}
|
||||
options={options}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="h-[200px]">
|
||||
{!isLoading && (
|
||||
<Line
|
||||
data={inventoryValueData}
|
||||
options={options}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Tabs defaultValue="revenue" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="revenue">Revenue</TabsTrigger>
|
||||
<TabsTrigger value="inventory">Inventory Value</TabsTrigger>
|
||||
<TabsTrigger value="gmroi">GMROI</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="revenue" className="space-y-4">
|
||||
<div className="h-[300px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={metrics?.revenue}>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => value}
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={formatCurrency}
|
||||
/>
|
||||
<Tooltip
|
||||
content={({ active, payload }) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-background p-2 shadow-sm">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[0.70rem] uppercase text-muted-foreground">
|
||||
Date
|
||||
</span>
|
||||
<span className="font-bold">
|
||||
{payload[0].payload.date}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[0.70rem] uppercase text-muted-foreground">
|
||||
Revenue
|
||||
</span>
|
||||
<span className="font-bold">
|
||||
{formatCurrency(payload[0].value as number)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke="#0ea5e9"
|
||||
fill="#0ea5e9"
|
||||
fillOpacity={0.2}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="inventory" className="space-y-4">
|
||||
<div className="h-[300px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={metrics?.inventory_value}>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => value}
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={formatCurrency}
|
||||
/>
|
||||
<Tooltip
|
||||
content={({ active, payload }) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-background p-2 shadow-sm">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[0.70rem] uppercase text-muted-foreground">
|
||||
Date
|
||||
</span>
|
||||
<span className="font-bold">
|
||||
{payload[0].payload.date}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[0.70rem] uppercase text-muted-foreground">
|
||||
Value
|
||||
</span>
|
||||
<span className="font-bold">
|
||||
{formatCurrency(payload[0].value as number)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke="#84cc16"
|
||||
fill="#84cc16"
|
||||
fillOpacity={0.2}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="gmroi" className="space-y-4">
|
||||
<div className="h-[300px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={metrics?.gmroi}>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => value}
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => `${value.toFixed(1)}%`}
|
||||
/>
|
||||
<Tooltip
|
||||
content={({ active, payload }) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-background p-2 shadow-sm">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[0.70rem] uppercase text-muted-foreground">
|
||||
Date
|
||||
</span>
|
||||
<span className="font-bold">
|
||||
{payload[0].payload.date}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[0.70rem] uppercase text-muted-foreground">
|
||||
GMROI
|
||||
</span>
|
||||
<span className="font-bold">
|
||||
{`${typeof payload[0].value === 'number' ? payload[0].value.toFixed(1) : payload[0].value}%`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke="#f59e0b"
|
||||
fill="#f59e0b"
|
||||
fillOpacity={0.2}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)
|
||||
}
|
||||
87
inventory/src/components/dashboard/LowStockAlerts.tsx
Normal file
87
inventory/src/components/dashboard/LowStockAlerts.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { CardHeader, CardTitle, CardContent } from "@/components/ui/card"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { AlertCircle, AlertTriangle } from "lucide-react"
|
||||
import config from "@/config"
|
||||
|
||||
interface LowStockProduct {
|
||||
product_id: number
|
||||
sku: string
|
||||
title: string
|
||||
stock_quantity: number
|
||||
reorder_point: number
|
||||
days_of_inventory: number
|
||||
stock_status: "Critical" | "Reorder"
|
||||
daily_sales_avg: number
|
||||
}
|
||||
|
||||
export function LowStockAlerts() {
|
||||
const { data: products } = useQuery<LowStockProduct[]>({
|
||||
queryKey: ["low-stock"],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${config.apiUrl}/dashboard/inventory/low-stock`)
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch low stock products")
|
||||
}
|
||||
return response.json()
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-medium">Low Stock Alerts</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="max-h-[350px] overflow-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>SKU</TableHead>
|
||||
<TableHead>Product</TableHead>
|
||||
<TableHead className="text-right">Stock</TableHead>
|
||||
<TableHead className="text-right">Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{products?.map((product) => (
|
||||
<TableRow key={product.product_id}>
|
||||
<TableCell className="font-medium">{product.sku}</TableCell>
|
||||
<TableCell>{product.title}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{product.stock_quantity} / {product.reorder_point}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
product.stock_status === "Critical"
|
||||
? "border-destructive text-destructive"
|
||||
: "border-warning text-warning"
|
||||
}
|
||||
>
|
||||
{product.stock_status === "Critical" ? (
|
||||
<AlertCircle className="mr-1 h-3 w-3" />
|
||||
) : (
|
||||
<AlertTriangle className="mr-1 h-3 w-3" />
|
||||
)}
|
||||
{product.stock_status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Link } from "react-router-dom"
|
||||
|
||||
interface StockAlert {
|
||||
product_id: number
|
||||
sku: string
|
||||
title: string
|
||||
stock_quantity: number
|
||||
daily_sales_avg: number
|
||||
days_of_inventory: number
|
||||
reorder_point: number
|
||||
stock_status: 'Critical' | 'Reorder'
|
||||
}
|
||||
|
||||
export function StockAlerts() {
|
||||
const { data, isLoading } = useQuery<StockAlert[]>({
|
||||
queryKey: ['stockAlerts'],
|
||||
queryFn: async () => {
|
||||
const response = await fetch('/api/inventory/alerts')
|
||||
if (!response.ok) {
|
||||
throw new Error('Network response was not ok')
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<Card className="col-span-8">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle>Low Stock Alerts</CardTitle>
|
||||
<Button asChild>
|
||||
<Link to="/inventory/replenishment">View All</Link>
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>SKU</TableHead>
|
||||
<TableHead>Title</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Stock</TableHead>
|
||||
<TableHead className="text-right">Daily Sales</TableHead>
|
||||
<TableHead className="text-right">Days Left</TableHead>
|
||||
<TableHead className="text-right">Reorder Point</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{!isLoading && data?.map((alert) => (
|
||||
<TableRow key={alert.product_id}>
|
||||
<TableCell className="font-medium">
|
||||
<Link
|
||||
to={`/products/${alert.product_id}`}
|
||||
className="text-blue-500 hover:underline"
|
||||
>
|
||||
{alert.sku}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>{alert.title}</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={alert.stock_status === 'Critical' ? 'destructive' : 'warning'}
|
||||
>
|
||||
{alert.stock_status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">{alert.stock_quantity}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{alert.daily_sales_avg.toFixed(1)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{alert.days_of_inventory.toFixed(1)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">{alert.reorder_point}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,76 +1,96 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { Link } from "react-router-dom"
|
||||
import { ArrowUpIcon, ArrowDownIcon } from "lucide-react"
|
||||
import { CardHeader, CardTitle, CardContent } from "@/components/ui/card"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import { TrendingUp, TrendingDown } from "lucide-react"
|
||||
import config from "@/config"
|
||||
|
||||
interface TrendingProduct {
|
||||
product_id: number
|
||||
sku: string
|
||||
title: string
|
||||
daily_sales_avg: number
|
||||
weekly_sales_avg: number
|
||||
growth_rate: number // Percentage growth week over week
|
||||
total_revenue: number
|
||||
daily_sales_avg: string
|
||||
weekly_sales_avg: string
|
||||
growth_rate: string
|
||||
total_revenue: string
|
||||
}
|
||||
|
||||
export function TrendingProducts() {
|
||||
const { data, isLoading } = useQuery<TrendingProduct[]>({
|
||||
queryKey: ['trendingProducts'],
|
||||
const { data: products } = useQuery<TrendingProduct[]>({
|
||||
queryKey: ["trending-products"],
|
||||
queryFn: async () => {
|
||||
const response = await fetch('/api/products/trending')
|
||||
const response = await fetch(`${config.apiUrl}/dashboard/products/trending`)
|
||||
if (!response.ok) {
|
||||
throw new Error('Network response was not ok')
|
||||
throw new Error("Failed to fetch trending products")
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
const formatPercent = (value: number) =>
|
||||
new Intl.NumberFormat("en-US", {
|
||||
style: "percent",
|
||||
minimumFractionDigits: 1,
|
||||
maximumFractionDigits: 1,
|
||||
signDisplay: "exceptZero",
|
||||
}).format(value / 100)
|
||||
|
||||
return (
|
||||
<Card className="col-span-4">
|
||||
<>
|
||||
<CardHeader>
|
||||
<CardTitle>Trending Products</CardTitle>
|
||||
<CardTitle className="text-lg font-medium">Trending Products</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-8">
|
||||
{!isLoading && data?.map((product) => (
|
||||
<div key={product.product_id} className="flex items-center">
|
||||
<div className="space-y-1 flex-1">
|
||||
<Link
|
||||
to={`/products/${product.product_id}`}
|
||||
className="text-sm font-medium leading-none hover:underline"
|
||||
>
|
||||
{product.sku}
|
||||
</Link>
|
||||
<p className="text-sm text-muted-foreground line-clamp-1">
|
||||
{product.title}
|
||||
</p>
|
||||
</div>
|
||||
<div className="ml-auto font-medium text-right space-y-1">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<span className="text-sm">
|
||||
{product.daily_sales_avg.toFixed(1)}/day
|
||||
</span>
|
||||
<div className={`flex items-center ${
|
||||
product.growth_rate >= 0 ? 'text-green-500' : 'text-red-500'
|
||||
}`}>
|
||||
{product.growth_rate >= 0 ? (
|
||||
<ArrowUpIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<ArrowDownIcon className="h-4 w-4" />
|
||||
)}
|
||||
<span className="text-xs">
|
||||
{Math.abs(product.growth_rate).toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
${product.total_revenue.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="max-h-[400px] overflow-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Product</TableHead>
|
||||
<TableHead>Daily Sales</TableHead>
|
||||
<TableHead className="text-right">Growth</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{products?.map((product) => (
|
||||
<TableRow key={product.product_id}>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{product.title}</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{product.sku}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{parseFloat(product.daily_sales_avg).toFixed(1)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{parseFloat(product.growth_rate) > 0 ? (
|
||||
<TrendingUp className="h-4 w-4 text-success" />
|
||||
) : (
|
||||
<TrendingDown className="h-4 w-4 text-destructive" />
|
||||
)}
|
||||
<span
|
||||
className={
|
||||
parseFloat(product.growth_rate) > 0 ? "text-success" : "text-destructive"
|
||||
}
|
||||
>
|
||||
{formatPercent(parseFloat(product.growth_rate))}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,15 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { CardHeader, CardTitle, CardContent } from "@/components/ui/card"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
import config from "@/config"
|
||||
|
||||
interface VendorMetrics {
|
||||
vendor: string
|
||||
@@ -12,66 +21,58 @@ interface VendorMetrics {
|
||||
}
|
||||
|
||||
export function VendorPerformance() {
|
||||
const { data, isLoading } = useQuery<VendorMetrics[]>({
|
||||
queryKey: ['vendorMetrics'],
|
||||
const { data: vendors } = useQuery<VendorMetrics[]>({
|
||||
queryKey: ["vendor-metrics"],
|
||||
queryFn: async () => {
|
||||
const response = await fetch('/api/vendors/metrics')
|
||||
const response = await fetch(`${config.apiUrl}/dashboard/vendors/metrics`)
|
||||
if (!response.ok) {
|
||||
throw new Error('Network response was not ok')
|
||||
throw new Error("Failed to fetch vendor metrics")
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// Sort vendors by on-time delivery rate
|
||||
const sortedVendors = data?.sort((a, b) =>
|
||||
b.on_time_delivery_rate - a.on_time_delivery_rate
|
||||
).slice(0, 5)
|
||||
const sortedVendors = vendors
|
||||
?.sort((a, b) => b.on_time_delivery_rate - a.on_time_delivery_rate)
|
||||
|
||||
return (
|
||||
<Card className="col-span-4">
|
||||
<>
|
||||
<CardHeader>
|
||||
<CardTitle>Top Vendor Performance</CardTitle>
|
||||
<CardTitle className="text-lg font-medium">Top Vendor Performance</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-8">
|
||||
{!isLoading && sortedVendors?.map((vendor) => (
|
||||
<div key={vendor.vendor} className="space-y-2">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-1 space-y-1">
|
||||
<p className="text-sm font-medium leading-none">
|
||||
{vendor.vendor}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{vendor.total_orders} orders, avg {vendor.avg_lead_time_days.toFixed(1)} days
|
||||
</p>
|
||||
</div>
|
||||
<div className="ml-auto font-medium">
|
||||
{vendor.on_time_delivery_rate.toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center text-xs text-muted-foreground justify-between">
|
||||
<span>On-time Delivery</span>
|
||||
<span>{vendor.on_time_delivery_rate.toFixed(1)}%</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={vendor.on_time_delivery_rate}
|
||||
className="h-1"
|
||||
/>
|
||||
<div className="flex items-center text-xs text-muted-foreground justify-between">
|
||||
<span>Order Fill Rate</span>
|
||||
<span>{vendor.order_fill_rate.toFixed(1)}%</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={vendor.order_fill_rate}
|
||||
className="h-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<CardContent className="max-h-[400px] overflow-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Vendor</TableHead>
|
||||
<TableHead>On-Time</TableHead>
|
||||
<TableHead className="text-right">Fill Rate</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sortedVendors?.map((vendor) => (
|
||||
<TableRow key={vendor.vendor}>
|
||||
<TableCell className="font-medium">{vendor.vendor}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Progress
|
||||
value={vendor.on_time_delivery_rate}
|
||||
className="h-2"
|
||||
/>
|
||||
<span className="w-10 text-sm">
|
||||
{vendor.on_time_delivery_rate.toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{vendor.order_fill_rate.toFixed(0)}%
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
const isDev = import.meta.env.DEV;
|
||||
|
||||
const config = {
|
||||
apiUrl: isDev ? '/api' : 'https://inventory.kent.pw/api',
|
||||
|
||||
@@ -1,22 +1,37 @@
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { InventoryHealthSummary } from "@/components/dashboard/InventoryHealthSummary"
|
||||
import { StockAlerts } from "@/components/dashboard/StockAlerts"
|
||||
import { LowStockAlerts } from "@/components/dashboard/LowStockAlerts"
|
||||
import { TrendingProducts } from "@/components/dashboard/TrendingProducts"
|
||||
import { VendorPerformance } from "@/components/dashboard/VendorPerformance"
|
||||
import { KeyMetricsCharts } from "@/components/dashboard/KeyMetricsCharts"
|
||||
|
||||
export function Dashboard() {
|
||||
return (
|
||||
<div className="flex-1 space-y-4 p-8 pt-6">
|
||||
<div className="flex-1 space-y-4 p-4 md:p-8 pt-6">
|
||||
<div className="flex items-center justify-between space-y-2">
|
||||
<h2 className="text-3xl font-bold tracking-tight">Dashboard</h2>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-12">
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<InventoryHealthSummary />
|
||||
<VendorPerformance />
|
||||
<KeyMetricsCharts />
|
||||
<StockAlerts />
|
||||
<TrendingProducts />
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
|
||||
<Card className="col-span-4">
|
||||
<KeyMetricsCharts />
|
||||
</Card>
|
||||
<Card className="col-span-3">
|
||||
<LowStockAlerts />
|
||||
</Card>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
|
||||
<Card className="col-span-4">
|
||||
<TrendingProducts />
|
||||
</Card>
|
||||
<Card className="col-span-3">
|
||||
<VendorPerformance />
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default Dashboard
|
||||
Reference in New Issue
Block a user