Clean up inventory overview page
This commit is contained in:
@@ -19,8 +19,8 @@ import config from '../../config';
|
||||
import { METRIC_COLORS } from '@/lib/dashboard/designTokens';
|
||||
import { formatCurrency } from '@/utils/formatCurrency';
|
||||
|
||||
interface VendorData {
|
||||
vendor: string;
|
||||
interface BrandData {
|
||||
brand: string;
|
||||
productCount: number;
|
||||
stockCost: number;
|
||||
profit30d: number;
|
||||
@@ -29,7 +29,7 @@ interface VendorData {
|
||||
}
|
||||
|
||||
interface EfficiencyData {
|
||||
vendors: VendorData[];
|
||||
brands: BrandData[];
|
||||
}
|
||||
|
||||
function getGmroiColor(gmroi: number): string {
|
||||
@@ -79,8 +79,8 @@ export function CapitalEfficiency() {
|
||||
|
||||
// Top or bottom 15 by GMROI for bar chart
|
||||
const sortedGmroi = gmroiView === 'top'
|
||||
? [...data.vendors].sort((a, b) => b.gmroi - a.gmroi).slice(0, 15)
|
||||
: [...data.vendors].sort((a, b) => a.gmroi - b.gmroi).slice(0, 15);
|
||||
? [...data.brands].sort((a, b) => b.gmroi - a.gmroi).slice(0, 15)
|
||||
: [...data.brands].sort((a, b) => a.gmroi - b.gmroi).slice(0, 15);
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
@@ -88,9 +88,9 @@ export function CapitalEfficiency() {
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>GMROI by Vendor</CardTitle>
|
||||
<CardTitle>GMROI by Brand</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Annualized gross margin return on investment (top 30 vendors by stock value)
|
||||
Annualized gross margin return on investment (top 30 brands by stock value)
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
@@ -117,17 +117,17 @@ export function CapitalEfficiency() {
|
||||
<XAxis type="number" tick={{ fontSize: 11 }} />
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="vendor"
|
||||
dataKey="brand"
|
||||
width={140}
|
||||
tick={{ fontSize: 11 }}
|
||||
/>
|
||||
<Tooltip
|
||||
content={({ active, payload }) => {
|
||||
if (!active || !payload?.length) return null;
|
||||
const d = payload[0].payload as VendorData;
|
||||
const d = payload[0].payload as BrandData;
|
||||
return (
|
||||
<div className="rounded-lg border bg-background p-3 shadow-md text-sm">
|
||||
<p className="font-medium mb-1">{d.vendor}</p>
|
||||
<p className="font-medium mb-1">{d.brand}</p>
|
||||
<p>GMROI: <span className="font-medium">{d.gmroi.toFixed(2)}</span></p>
|
||||
<p>Stock Investment: {formatCurrency(d.stockCost)}</p>
|
||||
<p>Profit (30d): {formatCurrency(d.profit30d)}</p>
|
||||
@@ -150,7 +150,7 @@ export function CapitalEfficiency() {
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Investment vs Profit by Vendor</CardTitle>
|
||||
<CardTitle>Investment vs Profit by Brand</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Bubble size = product count. Ideal: high profit, low stock cost.
|
||||
</p>
|
||||
@@ -179,10 +179,10 @@ export function CapitalEfficiency() {
|
||||
<Tooltip
|
||||
content={({ active, payload }) => {
|
||||
if (!active || !payload?.length) return null;
|
||||
const d = payload[0].payload as VendorData;
|
||||
const d = payload[0].payload as BrandData;
|
||||
return (
|
||||
<div className="rounded-lg border bg-background p-3 shadow-md text-sm">
|
||||
<p className="font-medium mb-1">{d.vendor}</p>
|
||||
<p className="font-medium mb-1">{d.brand}</p>
|
||||
<p>Stock Investment: {formatCurrency(d.stockCost)}</p>
|
||||
<p>Profit (30d): {formatCurrency(d.profit30d)}</p>
|
||||
<p>Revenue (30d): {formatCurrency(d.revenue30d)}</p>
|
||||
@@ -191,7 +191,7 @@ export function CapitalEfficiency() {
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Scatter data={data.vendors} fill={METRIC_COLORS.orders} fillOpacity={0.6} />
|
||||
<Scatter data={data.brands} fill={METRIC_COLORS.orders} fillOpacity={0.6} />
|
||||
</ScatterChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
} from 'recharts';
|
||||
import config from '../../config';
|
||||
import { METRIC_COLORS } from '@/lib/dashboard/designTokens';
|
||||
import { TrendingUp, TrendingDown } from 'lucide-react';
|
||||
import { TrendingUp, TrendingDown, Plus, Archive } from 'lucide-react';
|
||||
import { formatCurrency } from '@/utils/formatCurrency';
|
||||
|
||||
interface GrowthRow {
|
||||
abcClass: string;
|
||||
@@ -23,16 +24,24 @@ interface GrowthRow {
|
||||
}
|
||||
|
||||
interface GrowthSummary {
|
||||
totalWithYoy: number;
|
||||
comparableCount: number;
|
||||
growingCount: number;
|
||||
decliningCount: number;
|
||||
avgGrowth: number;
|
||||
weightedAvgGrowth: number;
|
||||
medianGrowth: number;
|
||||
}
|
||||
|
||||
interface CatalogTurnover {
|
||||
newProducts: number;
|
||||
newProductRevenue: number;
|
||||
discontinued: number;
|
||||
discontinuedStockValue: number;
|
||||
}
|
||||
|
||||
interface GrowthData {
|
||||
byClass: GrowthRow[];
|
||||
summary: GrowthSummary;
|
||||
turnover: CatalogTurnover;
|
||||
}
|
||||
|
||||
const GROWTH_COLORS: Record<string, string> = {
|
||||
@@ -78,9 +87,9 @@ export function GrowthMomentum() {
|
||||
);
|
||||
}
|
||||
|
||||
const { summary } = data;
|
||||
const growthPct = summary.totalWithYoy > 0
|
||||
? ((summary.growingCount / summary.totalWithYoy) * 100).toFixed(0)
|
||||
const { summary, turnover } = data;
|
||||
const growingPct = summary.comparableCount > 0
|
||||
? ((summary.growingCount / summary.comparableCount) * 100).toFixed(0)
|
||||
: '0';
|
||||
|
||||
// Pivot: for each ABC class, show product counts by growth bucket
|
||||
@@ -97,6 +106,7 @@ export function GrowthMomentum() {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Row 1: Comparable growth metrics */}
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 py-4">
|
||||
@@ -104,9 +114,9 @@ export function GrowthMomentum() {
|
||||
<TrendingUp className="h-4 w-4 text-green-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">Growing</p>
|
||||
<p className="text-xl font-bold">{growthPct}%</p>
|
||||
<p className="text-xs text-muted-foreground">{summary.growingCount.toLocaleString()} products</p>
|
||||
<p className="text-sm font-medium">Comparable Growing</p>
|
||||
<p className="text-xl font-bold">{growingPct}%</p>
|
||||
<p className="text-xs text-muted-foreground">{summary.growingCount.toLocaleString()} of {summary.comparableCount.toLocaleString()} products</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -116,18 +126,19 @@ export function GrowthMomentum() {
|
||||
<TrendingDown className="h-4 w-4 text-red-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">Declining</p>
|
||||
<p className="text-sm font-medium">Comparable Declining</p>
|
||||
<p className="text-xl font-bold">{summary.decliningCount.toLocaleString()}</p>
|
||||
<p className="text-xs text-muted-foreground">products</p>
|
||||
<p className="text-xs text-muted-foreground">products with lower YoY sales</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<p className="text-sm font-medium text-muted-foreground">Avg YoY Growth</p>
|
||||
<p className={`text-2xl font-bold ${summary.avgGrowth >= 0 ? 'text-green-500' : 'text-red-500'}`}>
|
||||
{summary.avgGrowth > 0 ? '+' : ''}{summary.avgGrowth}%
|
||||
<p className="text-sm font-medium text-muted-foreground">Weighted Avg Growth</p>
|
||||
<p className={`text-2xl font-bold ${summary.weightedAvgGrowth >= 0 ? 'text-green-500' : 'text-red-500'}`}>
|
||||
{summary.weightedAvgGrowth > 0 ? '+' : ''}{summary.weightedAvgGrowth}%
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">revenue-weighted</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
@@ -136,16 +147,49 @@ export function GrowthMomentum() {
|
||||
<p className={`text-2xl font-bold ${summary.medianGrowth >= 0 ? 'text-green-500' : 'text-red-500'}`}>
|
||||
{summary.medianGrowth > 0 ? '+' : ''}{summary.medianGrowth}%
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{summary.totalWithYoy.toLocaleString()} products tracked</p>
|
||||
<p className="text-xs text-muted-foreground">typical product growth</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Catalog turnover */}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 py-4">
|
||||
<div className="rounded-full p-2 bg-blue-500/10">
|
||||
<Plus className="h-4 w-4 text-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">New Products (<1yr)</p>
|
||||
<p className="text-xl font-bold">{turnover.newProducts.toLocaleString()}</p>
|
||||
<p className="text-xs text-muted-foreground">{formatCurrency(turnover.newProductRevenue)} revenue (30d)</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 py-4">
|
||||
<div className="rounded-full p-2 bg-amber-500/10">
|
||||
<Archive className="h-4 w-4 text-amber-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">Discontinued</p>
|
||||
<p className="text-xl font-bold">{turnover.discontinued.toLocaleString()}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{turnover.discontinuedStockValue > 0
|
||||
? `${formatCurrency(turnover.discontinuedStockValue)} still in stock`
|
||||
: 'no remaining stock'}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Chart: comparable products only */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Growth Distribution by ABC Class</CardTitle>
|
||||
<CardTitle>Comparable Growth by ABC Class</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Year-over-year sales growth segmented by product importance
|
||||
Products selling in both this and last year's period — excludes new launches and discontinued
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
||||
@@ -18,7 +18,7 @@ import { formatCurrency } from '@/utils/formatCurrency';
|
||||
interface RiskProduct {
|
||||
title: string;
|
||||
sku: string;
|
||||
vendor: string;
|
||||
brand: string;
|
||||
leadTimeDays: number;
|
||||
sellsOutInDays: number;
|
||||
currentStock: number;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import config from "@/config"
|
||||
import { formatCurrency } from "@/lib/utils"
|
||||
import { formatCurrency } from "@/utils/formatCurrency"
|
||||
|
||||
interface Product {
|
||||
pid: number;
|
||||
@@ -22,7 +22,6 @@ interface Category {
|
||||
units_sold: number;
|
||||
revenue: string;
|
||||
profit: string;
|
||||
growth_rate: string;
|
||||
}
|
||||
|
||||
interface BestSellerBrand {
|
||||
@@ -39,14 +38,22 @@ interface BestSellersData {
|
||||
categories: Category[]
|
||||
}
|
||||
|
||||
function TableSkeleton() {
|
||||
return (
|
||||
<div className="space-y-3 p-4">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="h-8 animate-pulse rounded bg-muted" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BestSellers() {
|
||||
const { data } = useQuery<BestSellersData>({
|
||||
const { data, isError, isLoading } = useQuery<BestSellersData>({
|
||||
queryKey: ["best-sellers"],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${config.apiUrl}/dashboard/best-sellers`)
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch best sellers")
|
||||
}
|
||||
if (!response.ok) throw new Error("Failed to fetch best sellers");
|
||||
return response.json()
|
||||
},
|
||||
})
|
||||
@@ -65,111 +72,121 @@ export function BestSellers() {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TabsContent value="products">
|
||||
<ScrollArea className="h-[385px] w-full">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Product</TableHead>
|
||||
<TableHead className="text-right">Units Sold</TableHead>
|
||||
<TableHead className="text-right">Revenue</TableHead>
|
||||
<TableHead className="text-right">Profit</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data?.products.map((product) => (
|
||||
<TableRow key={product.pid}>
|
||||
<TableCell>
|
||||
<a
|
||||
href={`https://backend.acherryontop.com/product/${product.pid}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:underline"
|
||||
>
|
||||
{product.title}
|
||||
</a>
|
||||
<div className="text-sm text-muted-foreground">{product.sku}</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">{product.units_sold}</TableCell>
|
||||
<TableCell className="text-right">{formatCurrency(Number(product.revenue))}</TableCell>
|
||||
<TableCell className="text-right">{formatCurrency(Number(product.profit))}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
{isError ? (
|
||||
<p className="text-sm text-destructive">Failed to load best sellers</p>
|
||||
) : isLoading ? (
|
||||
<TableSkeleton />
|
||||
) : (
|
||||
<>
|
||||
<TabsContent value="products">
|
||||
<ScrollArea className="h-[385px] w-full">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Product</TableHead>
|
||||
<TableHead className="text-right">Units Sold</TableHead>
|
||||
<TableHead className="text-right">Revenue</TableHead>
|
||||
<TableHead className="text-right">Profit</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data?.products.map((product) => (
|
||||
<TableRow key={product.pid}>
|
||||
<TableCell>
|
||||
<a
|
||||
href={`https://backend.acherryontop.com/product/${product.pid}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:underline"
|
||||
>
|
||||
{product.title}
|
||||
</a>
|
||||
<div className="text-sm text-muted-foreground">{product.sku}</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">{product.units_sold}</TableCell>
|
||||
<TableCell className="text-right">{formatCurrency(Number(product.revenue))}</TableCell>
|
||||
<TableCell className="text-right">{formatCurrency(Number(product.profit))}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="brands">
|
||||
<ScrollArea className="h-[400px] w-full">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[40%]">Brand</TableHead>
|
||||
<TableHead className="w-[15%] text-right">Sales</TableHead>
|
||||
<TableHead className="w-[15%] text-right">Revenue</TableHead>
|
||||
<TableHead className="w-[15%] text-right">Profit</TableHead>
|
||||
<TableHead className="w-[15%] text-right">Growth</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data?.brands.map((brand) => (
|
||||
<TableRow key={brand.brand}>
|
||||
<TableCell className="w-[40%]">
|
||||
<p className="font-medium">{brand.brand}</p>
|
||||
</TableCell>
|
||||
<TableCell className="w-[15%] text-right">
|
||||
{brand.units_sold.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="w-[15%] text-right">
|
||||
{formatCurrency(Number(brand.revenue))}
|
||||
</TableCell>
|
||||
<TableCell className="w-[15%] text-right">
|
||||
{formatCurrency(Number(brand.profit))}
|
||||
</TableCell>
|
||||
<TableCell className="w-[15%] text-right">
|
||||
{Number(brand.growth_rate) > 0 ? '+' : ''}{Number(brand.growth_rate).toFixed(1)}%
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
<TabsContent value="brands">
|
||||
<ScrollArea className="h-[400px] w-full">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[40%]">Brand</TableHead>
|
||||
<TableHead className="w-[15%] text-right">Sales</TableHead>
|
||||
<TableHead className="w-[15%] text-right">Revenue</TableHead>
|
||||
<TableHead className="w-[15%] text-right">Profit</TableHead>
|
||||
<TableHead className="w-[15%] text-right">Growth</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data?.brands.map((brand) => (
|
||||
<TableRow key={brand.brand}>
|
||||
<TableCell className="w-[40%]">
|
||||
<p className="font-medium">{brand.brand}</p>
|
||||
</TableCell>
|
||||
<TableCell className="w-[15%] text-right">
|
||||
{brand.units_sold.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="w-[15%] text-right">
|
||||
{formatCurrency(Number(brand.revenue))}
|
||||
</TableCell>
|
||||
<TableCell className="w-[15%] text-right">
|
||||
{formatCurrency(Number(brand.profit))}
|
||||
</TableCell>
|
||||
<TableCell className="w-[15%] text-right">
|
||||
{brand.growth_rate != null ? (
|
||||
<>{Number(brand.growth_rate) > 0 ? '+' : ''}{Number(brand.growth_rate).toFixed(1)}%</>
|
||||
) : '-'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="categories">
|
||||
<ScrollArea className="h-[400px] w-full">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Category</TableHead>
|
||||
<TableHead className="text-right">Units Sold</TableHead>
|
||||
<TableHead className="text-right">Revenue</TableHead>
|
||||
<TableHead className="text-right">Profit</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data?.categories.map((category) => (
|
||||
<TableRow key={category.cat_id}>
|
||||
<TableCell>
|
||||
<div className="font-medium">{category.name}</div>
|
||||
{category.categoryPath && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{category.categoryPath}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">{category.units_sold}</TableCell>
|
||||
<TableCell className="text-right">{formatCurrency(Number(category.revenue))}</TableCell>
|
||||
<TableCell className="text-right">{formatCurrency(Number(category.profit))}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
<TabsContent value="categories">
|
||||
<ScrollArea className="h-[400px] w-full">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Category</TableHead>
|
||||
<TableHead className="text-right">Units Sold</TableHead>
|
||||
<TableHead className="text-right">Revenue</TableHead>
|
||||
<TableHead className="text-right">Profit</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data?.categories.map((category) => (
|
||||
<TableRow key={category.cat_id}>
|
||||
<TableCell>
|
||||
<div className="font-medium">{category.name}</div>
|
||||
{category.categoryPath && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{category.categoryPath}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">{category.units_sold}</TableCell>
|
||||
<TableCell className="text-right">{formatCurrency(Number(category.revenue))}</TableCell>
|
||||
<TableCell className="text-right">{formatCurrency(Number(category.profit))}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Tabs>
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { CardHeader, CardTitle, CardContent } from "@/components/ui/card"
|
||||
import { AreaChart, Area, ResponsiveContainer, XAxis, YAxis, Tooltip } from "recharts"
|
||||
import { useState } from "react"
|
||||
import config from "@/config"
|
||||
import { formatCurrency } from "@/lib/utils"
|
||||
import { formatCurrency } from "@/utils/formatCurrency"
|
||||
import { TrendingUp, DollarSign } from "lucide-react"
|
||||
import { DateRange } from "react-day-picker"
|
||||
import { addDays, format } from "date-fns"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { CardHeader, CardTitle, CardContent } from "@/components/ui/card"
|
||||
import config from "@/config"
|
||||
import { formatCurrency } from "@/lib/utils"
|
||||
import { formatCurrency } from "@/utils/formatCurrency"
|
||||
import { Package, Layers, DollarSign, ShoppingCart } from "lucide-react"
|
||||
|
||||
interface OverstockMetricsData {
|
||||
@@ -18,15 +18,17 @@ interface OverstockMetricsData {
|
||||
}[]
|
||||
}
|
||||
|
||||
function MetricSkeleton() {
|
||||
return <div className="h-7 w-20 animate-pulse rounded bg-muted" />;
|
||||
}
|
||||
|
||||
export function OverstockMetrics() {
|
||||
const { data } = useQuery<OverstockMetricsData>({
|
||||
const { data, isError, isLoading } = useQuery<OverstockMetricsData>({
|
||||
queryKey: ["overstock-metrics"],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${config.apiUrl}/dashboard/overstock/metrics`)
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch overstock metrics")
|
||||
}
|
||||
return response.json()
|
||||
if (!response.ok) throw new Error('Failed to fetch overstock metrics');
|
||||
return response.json();
|
||||
},
|
||||
})
|
||||
|
||||
@@ -36,37 +38,49 @@ export function OverstockMetrics() {
|
||||
<CardTitle className="text-xl font-medium">Overstock</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Package className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Overstocked Products</p>
|
||||
{isError ? (
|
||||
<p className="text-sm text-destructive">Failed to load overstock metrics</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Package className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Overstocked Products</p>
|
||||
</div>
|
||||
{isLoading || !data ? <MetricSkeleton /> : (
|
||||
<p className="text-lg font-bold">{data.overstockedProducts.toLocaleString()}</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-lg font-bold">{data?.overstockedProducts.toLocaleString() || 0}</p>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Overstocked Units</p>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Overstocked Units</p>
|
||||
</div>
|
||||
{isLoading || !data ? <MetricSkeleton /> : (
|
||||
<p className="text-lg font-bold">{data.total_excess_units.toLocaleString()}</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-lg font-bold">{data?.total_excess_units.toLocaleString() || 0}</p>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Overstocked Cost</p>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Overstocked Cost</p>
|
||||
</div>
|
||||
{isLoading || !data ? <MetricSkeleton /> : (
|
||||
<p className="text-lg font-bold">{formatCurrency(data.total_excess_cost)}</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-lg font-bold">{formatCurrency(data?.total_excess_cost || 0)}</p>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Overstocked Retail</p>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Overstocked Retail</p>
|
||||
</div>
|
||||
{isLoading || !data ? <MetricSkeleton /> : (
|
||||
<p className="text-lg font-bold">{formatCurrency(data.total_excess_retail)}</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-lg font-bold">{formatCurrency(data?.total_excess_retail || 0)}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||
import config from '../../config';
|
||||
|
||||
interface SalesData {
|
||||
date: string;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export function Overview() {
|
||||
const { data, isLoading, error } = useQuery<SalesData[]>({
|
||||
queryKey: ['sales-overview'],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${config.apiUrl}/dashboard/sales-overview`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch sales overview');
|
||||
}
|
||||
const rawData = await response.json();
|
||||
return rawData.map((item: SalesData) => ({
|
||||
...item,
|
||||
total: parseFloat(item.total.toString()),
|
||||
date: new Date(item.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <div>Loading chart...</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="text-red-500">Error loading sales overview</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={350}>
|
||||
<LineChart data={data}>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
stroke="#888888"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="#888888"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => `$${value.toLocaleString()}`}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: number) => [`$${value.toLocaleString()}`, 'Sales']}
|
||||
labelFormatter={(label) => `Date: ${label}`}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="total"
|
||||
stroke="hsl(var(--primary))"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
@@ -2,16 +2,16 @@ import { useQuery } from "@tanstack/react-query"
|
||||
import { CardHeader, CardTitle, CardContent } from "@/components/ui/card"
|
||||
import { PieChart, Pie, ResponsiveContainer, Cell, Sector } from "recharts"
|
||||
import config from "@/config"
|
||||
import { formatCurrency } from "@/lib/utils"
|
||||
import { ClipboardList, AlertCircle, Layers, DollarSign, ShoppingCart } from "lucide-react" // Importing icons
|
||||
import { formatCurrency } from "@/utils/formatCurrency"
|
||||
import { ClipboardList, AlertCircle, Layers, DollarSign, ShoppingCart } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
|
||||
interface PurchaseMetricsData {
|
||||
activePurchaseOrders: number // Orders that are not canceled, done, or fully received
|
||||
overduePurchaseOrders: number // Orders past their expected delivery date
|
||||
onOrderUnits: number // Total units across all active orders
|
||||
onOrderCost: number // Total cost across all active orders
|
||||
onOrderRetail: number // Total retail value across all active orders
|
||||
activePurchaseOrders: number
|
||||
overduePurchaseOrders: number
|
||||
onOrderUnits: number
|
||||
onOrderCost: number
|
||||
onOrderRetail: number
|
||||
vendorOrders: {
|
||||
vendor: string
|
||||
orders: number
|
||||
@@ -34,12 +34,11 @@ const COLORS = [
|
||||
|
||||
const renderActiveShape = (props: any) => {
|
||||
const { cx, cy, innerRadius, outerRadius, startAngle, endAngle, fill, vendor, cost } = props;
|
||||
|
||||
// Split vendor name into words and create lines of max 12 chars
|
||||
|
||||
const words = vendor.split(' ');
|
||||
const lines: string[] = [];
|
||||
let currentLine = '';
|
||||
|
||||
|
||||
words.forEach((word: string) => {
|
||||
if ((currentLine + ' ' + word).length <= 12) {
|
||||
currentLine = currentLine ? `${currentLine} ${word}` : word;
|
||||
@@ -52,151 +51,136 @@ const renderActiveShape = (props: any) => {
|
||||
|
||||
return (
|
||||
<g>
|
||||
<Sector
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
innerRadius={innerRadius}
|
||||
outerRadius={outerRadius}
|
||||
startAngle={startAngle}
|
||||
endAngle={endAngle}
|
||||
fill={fill}
|
||||
/>
|
||||
<Sector
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
startAngle={startAngle}
|
||||
endAngle={endAngle}
|
||||
innerRadius={outerRadius - 1}
|
||||
outerRadius={outerRadius + 4}
|
||||
fill={fill}
|
||||
/>
|
||||
<Sector cx={cx} cy={cy} innerRadius={innerRadius} outerRadius={outerRadius} startAngle={startAngle} endAngle={endAngle} fill={fill} />
|
||||
<Sector cx={cx} cy={cy} startAngle={startAngle} endAngle={endAngle} innerRadius={outerRadius - 1} outerRadius={outerRadius + 4} fill={fill} />
|
||||
{lines.map((line, i) => (
|
||||
<text
|
||||
key={i}
|
||||
x={cx}
|
||||
y={cy}
|
||||
dy={-20 + (i * 16)}
|
||||
textAnchor="middle"
|
||||
fill="#888888"
|
||||
className="text-xs"
|
||||
>
|
||||
<text key={i} x={cx} y={cy} dy={-20 + (i * 16)} textAnchor="middle" fill="#888888" className="text-xs">
|
||||
{line}
|
||||
</text>
|
||||
))}
|
||||
<text
|
||||
x={cx}
|
||||
y={cy}
|
||||
dy={lines.length * 16 - 10}
|
||||
textAnchor="middle"
|
||||
fill="#000000"
|
||||
className="text-base font-medium"
|
||||
>
|
||||
<text x={cx} y={cy} dy={lines.length * 16 - 10} textAnchor="middle" fill="#000000" className="text-base font-medium">
|
||||
{formatCurrency(cost)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
function MetricSkeleton() {
|
||||
return <div className="h-7 w-20 animate-pulse rounded bg-muted" />;
|
||||
}
|
||||
|
||||
export function PurchaseMetrics() {
|
||||
const [activeIndex, setActiveIndex] = useState<number | undefined>();
|
||||
|
||||
const { data, error, isLoading } = useQuery<PurchaseMetricsData>({
|
||||
const { data, isError, isLoading } = useQuery<PurchaseMetricsData>({
|
||||
queryKey: ["purchase-metrics"],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${config.apiUrl}/dashboard/purchase/metrics`)
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
console.error('API Error:', text);
|
||||
throw new Error(`Failed to fetch purchase metrics: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
return data;
|
||||
if (!response.ok) throw new Error('Failed to fetch purchase metrics');
|
||||
return response.json();
|
||||
},
|
||||
})
|
||||
|
||||
if (isLoading) return <div>Loading...</div>;
|
||||
if (error) return <div>Error loading purchase metrics</div>;
|
||||
|
||||
return (
|
||||
<>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl font-medium">Purchases</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex justify-between gap-8">
|
||||
<div className="flex-1">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<ClipboardList className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Active Purchase Orders</p>
|
||||
{isError ? (
|
||||
<p className="text-sm text-destructive">Failed to load purchase metrics</p>
|
||||
) : (
|
||||
<div className="flex justify-between gap-8">
|
||||
<div className="flex-1">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<ClipboardList className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Active Purchase Orders</p>
|
||||
</div>
|
||||
{isLoading || !data ? <MetricSkeleton /> : (
|
||||
<p className="text-lg font-bold">{data.activePurchaseOrders.toLocaleString()}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Overdue Purchase Orders</p>
|
||||
</div>
|
||||
{isLoading || !data ? <MetricSkeleton /> : (
|
||||
<p className="text-lg font-bold">{data.overduePurchaseOrders.toLocaleString()}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">On Order Units</p>
|
||||
</div>
|
||||
{isLoading || !data ? <MetricSkeleton /> : (
|
||||
<p className="text-lg font-bold">{data.onOrderUnits.toLocaleString()}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">On Order Cost</p>
|
||||
</div>
|
||||
{isLoading || !data ? <MetricSkeleton /> : (
|
||||
<p className="text-lg font-bold">{formatCurrency(data.onOrderCost)}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">On Order Retail</p>
|
||||
</div>
|
||||
{isLoading || !data ? <MetricSkeleton /> : (
|
||||
<p className="text-lg font-bold">{formatCurrency(data.onOrderRetail)}</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-lg font-bold">{data?.activePurchaseOrders.toLocaleString() || 0}</p>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Overdue Purchase Orders</p>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-md flex justify-center font-medium">Purchase Orders By Vendor</div>
|
||||
<div className="h-[180px]">
|
||||
{isLoading || !data ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="h-[160px] w-[160px] animate-pulse rounded-full bg-muted" />
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data.vendorOrders}
|
||||
dataKey="cost"
|
||||
nameKey="vendor"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={60}
|
||||
outerRadius={80}
|
||||
paddingAngle={1}
|
||||
activeIndex={activeIndex}
|
||||
activeShape={renderActiveShape}
|
||||
onMouseEnter={(_, index) => setActiveIndex(index)}
|
||||
onMouseLeave={() => setActiveIndex(undefined)}
|
||||
>
|
||||
{data.vendorOrders.map((entry, index) => (
|
||||
<Cell
|
||||
key={entry.vendor}
|
||||
fill={COLORS[index % COLORS.length]}
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-lg font-bold">{data?.overduePurchaseOrders.toLocaleString() || 0}</p>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">On Order Units</p>
|
||||
</div>
|
||||
<p className="text-lg font-bold">{data?.onOrderUnits.toLocaleString() || 0}</p>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">On Order Cost</p>
|
||||
</div>
|
||||
<p className="text-lg font-bold">{formatCurrency(data?.onOrderCost || 0)}</p>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">On Order Retail</p>
|
||||
</div>
|
||||
<p className="text-lg font-bold">{formatCurrency(data?.onOrderRetail || 0)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-md flex justify-center font-medium">Purchase Orders By Vendor</div>
|
||||
<div className="h-[180px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data?.vendorOrders || []}
|
||||
dataKey="cost"
|
||||
nameKey="vendor"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={60}
|
||||
outerRadius={80}
|
||||
paddingAngle={1}
|
||||
activeIndex={activeIndex}
|
||||
activeShape={renderActiveShape}
|
||||
onMouseEnter={(_, index) => setActiveIndex(index)}
|
||||
onMouseLeave={() => setActiveIndex(undefined)}
|
||||
>
|
||||
{data?.vendorOrders?.map((entry, index) => (
|
||||
<Cell
|
||||
key={entry.vendor}
|
||||
fill={COLORS[index % COLORS.length]}
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { CardHeader, CardTitle, CardContent } from "@/components/ui/card"
|
||||
import config from "@/config"
|
||||
import { formatCurrency } from "@/lib/utils"
|
||||
import { Package, DollarSign, ShoppingCart } from "lucide-react" // Importing icons
|
||||
import { formatCurrency } from "@/utils/formatCurrency"
|
||||
import { Package, DollarSign, ShoppingCart } from "lucide-react"
|
||||
|
||||
interface ReplenishmentMetricsData {
|
||||
productsToReplenish: number
|
||||
@@ -21,55 +21,60 @@ interface ReplenishmentMetricsData {
|
||||
}[]
|
||||
}
|
||||
|
||||
function MetricSkeleton() {
|
||||
return <div className="h-7 w-20 animate-pulse rounded bg-muted" />;
|
||||
}
|
||||
|
||||
export function ReplenishmentMetrics() {
|
||||
const { data, error, isLoading } = useQuery<ReplenishmentMetricsData>({
|
||||
const { data, isError, isLoading } = useQuery<ReplenishmentMetricsData>({
|
||||
queryKey: ["replenishment-metrics"],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${config.apiUrl}/dashboard/replenishment/metrics`)
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
console.error('API Error:', text);
|
||||
throw new Error(`Failed to fetch replenishment metrics: ${response.status} ${response.statusText} - ${text}`)
|
||||
}
|
||||
const data = await response.json();
|
||||
return data;
|
||||
if (!response.ok) throw new Error('Failed to fetch replenishment metrics');
|
||||
return response.json();
|
||||
},
|
||||
})
|
||||
|
||||
if (isLoading) return <div className="p-8 text-center">Loading replenishment metrics...</div>;
|
||||
if (error) return <div className="p-8 text-center text-red-500">Error: {error.message}</div>;
|
||||
if (!data) return <div className="p-8 text-center">No replenishment data available</div>;
|
||||
|
||||
return (
|
||||
<>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl font-medium">Replenishment</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Package className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Units to Replenish</p>
|
||||
{isError ? (
|
||||
<p className="text-sm text-destructive">Failed to load replenishment metrics</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Package className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Units to Replenish</p>
|
||||
</div>
|
||||
{isLoading || !data ? <MetricSkeleton /> : (
|
||||
<p className="text-lg font-bold">{data.unitsToReplenish.toLocaleString()}</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-lg font-bold">{data.unitsToReplenish.toLocaleString() || 0}</p>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Replenishment Cost</p>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Replenishment Cost</p>
|
||||
</div>
|
||||
{isLoading || !data ? <MetricSkeleton /> : (
|
||||
<p className="text-lg font-bold">{formatCurrency(data.replenishmentCost)}</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-lg font-bold">{formatCurrency(data.replenishmentCost || 0)}</p>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Replenishment Retail</p>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Replenishment Retail</p>
|
||||
</div>
|
||||
{isLoading || !data ? <MetricSkeleton /> : (
|
||||
<p className="text-lg font-bold">{formatCurrency(data.replenishmentRetail)}</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-lg font-bold">{formatCurrency(data.replenishmentRetail || 0)}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { CardHeader, CardTitle, CardContent } from "@/components/ui/card"
|
||||
import { AreaChart, Area, ResponsiveContainer, XAxis, YAxis, Tooltip } from "recharts"
|
||||
import { useState } from "react"
|
||||
import config from "@/config"
|
||||
import { formatCurrency } from "@/lib/utils"
|
||||
import { formatCurrency } from "@/utils/formatCurrency"
|
||||
import { ClipboardList, Package, DollarSign, ShoppingCart } from "lucide-react"
|
||||
import { DateRange } from "react-day-picker"
|
||||
import { addDays, format } from "date-fns"
|
||||
@@ -12,23 +12,27 @@ import { DateRangePicker } from "@/components/ui/date-range-picker-narrow"
|
||||
interface SalesData {
|
||||
totalOrders: number
|
||||
totalUnitsSold: number
|
||||
totalCogs: string
|
||||
totalRevenue: string
|
||||
totalCogs: number
|
||||
totalRevenue: number
|
||||
dailySales: {
|
||||
date: string
|
||||
units: number
|
||||
revenue: string
|
||||
cogs: string
|
||||
revenue: number
|
||||
cogs: number
|
||||
}[]
|
||||
}
|
||||
|
||||
function MetricSkeleton() {
|
||||
return <div className="h-7 w-20 animate-pulse rounded bg-muted" />;
|
||||
}
|
||||
|
||||
export function SalesMetrics() {
|
||||
const [dateRange, setDateRange] = useState<DateRange>({
|
||||
from: addDays(new Date(), -30),
|
||||
to: new Date(),
|
||||
});
|
||||
|
||||
const { data } = useQuery<SalesData>({
|
||||
const { data, isError, isLoading } = useQuery<SalesData>({
|
||||
queryKey: ["sales-metrics", dateRange],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams({
|
||||
@@ -36,9 +40,7 @@ export function SalesMetrics() {
|
||||
endDate: dateRange.to?.toISOString() || "",
|
||||
});
|
||||
const response = await fetch(`${config.apiUrl}/dashboard/sales/metrics?${params}`)
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch sales metrics")
|
||||
}
|
||||
if (!response.ok) throw new Error("Failed to fetch sales metrics");
|
||||
return response.json()
|
||||
},
|
||||
})
|
||||
@@ -58,70 +60,90 @@ export function SalesMetrics() {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="py-0 -mb-2">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<ClipboardList className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Total Orders</p>
|
||||
{isError ? (
|
||||
<p className="text-sm text-destructive">Failed to load sales metrics</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<ClipboardList className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Total Orders</p>
|
||||
</div>
|
||||
{isLoading || !data ? <MetricSkeleton /> : (
|
||||
<p className="text-lg font-bold">{data.totalOrders.toLocaleString()}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Package className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Units Sold</p>
|
||||
</div>
|
||||
{isLoading || !data ? <MetricSkeleton /> : (
|
||||
<p className="text-lg font-bold">{data.totalUnitsSold.toLocaleString()}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Cost of Goods</p>
|
||||
</div>
|
||||
{isLoading || !data ? <MetricSkeleton /> : (
|
||||
<p className="text-lg font-bold">{formatCurrency(Number(data.totalCogs))}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Revenue</p>
|
||||
</div>
|
||||
{isLoading || !data ? <MetricSkeleton /> : (
|
||||
<p className="text-lg font-bold">{formatCurrency(Number(data.totalRevenue))}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-lg font-bold">{data?.totalOrders.toLocaleString() || 0}</p>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Package className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Units Sold</p>
|
||||
</div>
|
||||
<p className="text-lg font-bold">{data?.totalUnitsSold.toLocaleString() || 0}</p>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Cost of Goods</p>
|
||||
</div>
|
||||
<p className="text-lg font-bold">{formatCurrency(Number(data?.totalCogs) || 0)}</p>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Revenue</p>
|
||||
</div>
|
||||
<p className="text-lg font-bold">{formatCurrency(Number(data?.totalRevenue) || 0)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-[250px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart
|
||||
data={data?.dailySales || []}
|
||||
margin={{ top: 30, right: 0, left: -60, bottom: 0 }}
|
||||
>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tick={false}
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tick={false}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: string) => [formatCurrency(Number(value)), "Revenue"]}
|
||||
labelFormatter={(date) => format(new Date(date), 'MMM d, yyyy')}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="revenue"
|
||||
name="Revenue"
|
||||
stroke="#00C49F"
|
||||
fill="#00C49F"
|
||||
fillOpacity={0.2}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="h-[250px] w-full">
|
||||
{isLoading ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="h-[200px] w-full animate-pulse rounded bg-muted" />
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart
|
||||
data={data?.dailySales || []}
|
||||
margin={{ top: 30, right: 0, left: -60, bottom: 0 }}
|
||||
>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tick={false}
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tick={false}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: string) => [formatCurrency(Number(value)), "Revenue"]}
|
||||
labelFormatter={(date) => format(new Date(date), 'MMM d, yyyy')}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="revenue"
|
||||
name="Revenue"
|
||||
stroke="#00C49F"
|
||||
fill="#00C49F"
|
||||
fillOpacity={0.2}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useQuery } from "@tanstack/react-query"
|
||||
import { CardHeader, CardTitle, CardContent } from "@/components/ui/card"
|
||||
import { PieChart, Pie, ResponsiveContainer, Cell, Sector } from "recharts"
|
||||
import config from "@/config"
|
||||
import { formatCurrency } from "@/lib/utils"
|
||||
import { formatCurrency } from "@/utils/formatCurrency"
|
||||
import { Package, Layers, DollarSign, ShoppingCart } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
|
||||
@@ -10,14 +10,14 @@ interface StockMetricsData {
|
||||
totalProducts: number
|
||||
productsInStock: number
|
||||
totalStockUnits: number
|
||||
totalStockCost: string
|
||||
totalStockRetail: string
|
||||
totalStockCost: number
|
||||
totalStockRetail: number
|
||||
brandStock: {
|
||||
brand: string
|
||||
variants: number
|
||||
units: number
|
||||
cost: string
|
||||
retail: string
|
||||
cost: number
|
||||
retail: number
|
||||
}[]
|
||||
}
|
||||
|
||||
@@ -34,12 +34,12 @@ const COLORS = [
|
||||
|
||||
const renderActiveShape = (props: any) => {
|
||||
const { cx, cy, innerRadius, outerRadius, startAngle, endAngle, fill, brand, retail } = props;
|
||||
|
||||
|
||||
// Split brand name into words and create lines of max 12 chars
|
||||
const words = brand.split(' ');
|
||||
const lines: string[] = [];
|
||||
let currentLine = '';
|
||||
|
||||
|
||||
words.forEach((word: string) => {
|
||||
if ((currentLine + ' ' + word).length <= 12) {
|
||||
currentLine = currentLine ? `${currentLine} ${word}` : word;
|
||||
@@ -71,132 +71,148 @@ const renderActiveShape = (props: any) => {
|
||||
fill={fill}
|
||||
/>
|
||||
{lines.map((line, i) => (
|
||||
<text
|
||||
<text
|
||||
key={i}
|
||||
x={cx}
|
||||
y={cy}
|
||||
dy={-20 + (i * 16)}
|
||||
textAnchor="middle"
|
||||
fill="#888888"
|
||||
x={cx}
|
||||
y={cy}
|
||||
dy={-20 + (i * 16)}
|
||||
textAnchor="middle"
|
||||
fill="#888888"
|
||||
className="text-xs"
|
||||
>
|
||||
{line}
|
||||
</text>
|
||||
))}
|
||||
<text
|
||||
x={cx}
|
||||
y={cy}
|
||||
dy={lines.length * 16 - 10}
|
||||
textAnchor="middle"
|
||||
fill="#000000"
|
||||
<text
|
||||
x={cx}
|
||||
y={cy}
|
||||
dy={lines.length * 16 - 10}
|
||||
textAnchor="middle"
|
||||
fill="#000000"
|
||||
className="text-base font-medium"
|
||||
>
|
||||
{formatCurrency(Number(retail))}
|
||||
{formatCurrency(retail)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
function MetricSkeleton() {
|
||||
return <div className="h-7 w-20 animate-pulse rounded bg-muted" />;
|
||||
}
|
||||
|
||||
export function StockMetrics() {
|
||||
const [activeIndex, setActiveIndex] = useState<number | undefined>();
|
||||
|
||||
const { data, error, isLoading } = useQuery<StockMetricsData>({
|
||||
|
||||
const { data, isError, isLoading } = useQuery<StockMetricsData>({
|
||||
queryKey: ["stock-metrics"],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${config.apiUrl}/dashboard/stock/metrics`);
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
console.error('API Error:', text);
|
||||
throw new Error(`Failed to fetch stock metrics: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
return data;
|
||||
if (!response.ok) throw new Error('Failed to fetch stock metrics');
|
||||
return response.json();
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) return <div>Loading...</div>;
|
||||
if (error) return <div>Error loading stock metrics</div>;
|
||||
|
||||
return (
|
||||
<>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl font-medium">Stock</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex justify-between gap-8">
|
||||
<div className="flex-1">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Package className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Products</p>
|
||||
{isError ? (
|
||||
<p className="text-sm text-destructive">Failed to load stock metrics</p>
|
||||
) : (
|
||||
<div className="flex justify-between gap-8">
|
||||
<div className="flex-1">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Package className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Products</p>
|
||||
</div>
|
||||
{isLoading || !data ? <MetricSkeleton /> : (
|
||||
<p className="text-lg font-bold">{data.totalProducts.toLocaleString()}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Products In Stock</p>
|
||||
</div>
|
||||
{isLoading || !data ? <MetricSkeleton /> : (
|
||||
<p className="text-lg font-bold">{data.productsInStock.toLocaleString()}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Stock Units</p>
|
||||
</div>
|
||||
{isLoading || !data ? <MetricSkeleton /> : (
|
||||
<p className="text-lg font-bold">{data.totalStockUnits.toLocaleString()}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Stock Cost</p>
|
||||
</div>
|
||||
{isLoading || !data ? <MetricSkeleton /> : (
|
||||
<p className="text-lg font-bold">{formatCurrency(data.totalStockCost)}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Stock Retail</p>
|
||||
</div>
|
||||
{isLoading || !data ? <MetricSkeleton /> : (
|
||||
<p className="text-lg font-bold">{formatCurrency(data.totalStockRetail)}</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-lg font-bold">{data?.totalProducts.toLocaleString() || 0}</p>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Products In Stock</p>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-md flex justify-center font-medium">Stock Retail By Brand</div>
|
||||
<div className="h-[180px]">
|
||||
{isLoading || !data ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="h-[160px] w-[160px] animate-pulse rounded-full bg-muted" />
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data.brandStock}
|
||||
dataKey="retail"
|
||||
nameKey="brand"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={60}
|
||||
outerRadius={80}
|
||||
paddingAngle={1}
|
||||
activeIndex={activeIndex}
|
||||
activeShape={renderActiveShape}
|
||||
onMouseEnter={(_, index) => setActiveIndex(index)}
|
||||
onMouseLeave={() => setActiveIndex(undefined)}
|
||||
>
|
||||
{data.brandStock.map((entry, index) => (
|
||||
<Cell
|
||||
key={entry.brand}
|
||||
fill={COLORS[index % COLORS.length]}
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-lg font-bold">{data?.productsInStock.toLocaleString() || 0}</p>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Stock Units</p>
|
||||
</div>
|
||||
<p className="text-lg font-bold">{data?.totalStockUnits.toLocaleString() || 0}</p>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Stock Cost</p>
|
||||
</div>
|
||||
<p className="text-lg font-bold">{formatCurrency(Number(data?.totalStockCost) || 0)}</p>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Stock Retail</p>
|
||||
</div>
|
||||
<p className="text-lg font-bold">{formatCurrency(Number(data?.totalStockRetail) || 0)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-md flex justify-center font-medium">Stock Retail By Brand</div>
|
||||
<div className="h-[180px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data?.brandStock || []}
|
||||
dataKey="retail"
|
||||
nameKey="brand"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={60}
|
||||
outerRadius={80}
|
||||
paddingAngle={1}
|
||||
activeIndex={activeIndex}
|
||||
activeShape={renderActiveShape}
|
||||
onMouseEnter={(_, index) => setActiveIndex(index)}
|
||||
onMouseLeave={() => setActiveIndex(undefined)}
|
||||
>
|
||||
{data?.brandStock?.map((entry, index) => (
|
||||
<Cell
|
||||
key={entry.brand}
|
||||
fill={COLORS[index % COLORS.length]}
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ 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"
|
||||
import { formatCurrency } from "@/utils/formatCurrency"
|
||||
|
||||
interface Product {
|
||||
pid: number;
|
||||
@@ -15,14 +15,22 @@ interface Product {
|
||||
excess_retail: number;
|
||||
}
|
||||
|
||||
function TableSkeleton() {
|
||||
return (
|
||||
<div className="space-y-3 p-4">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="h-8 animate-pulse rounded bg-muted" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TopOverstockedProducts() {
|
||||
const { data } = useQuery<Product[]>({
|
||||
const { data, isError, isLoading } = useQuery<Product[]>({
|
||||
queryKey: ["top-overstocked-products"],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${config.apiUrl}/dashboard/overstock/products?limit=50`)
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch overstocked products")
|
||||
}
|
||||
if (!response.ok) throw new Error("Failed to fetch overstocked products");
|
||||
return response.json()
|
||||
},
|
||||
})
|
||||
@@ -33,41 +41,47 @@ export function TopOverstockedProducts() {
|
||||
<CardTitle className="text-xl font-medium">Top Overstocked Products</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ScrollArea className="h-[300px] w-full">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Product</TableHead>
|
||||
<TableHead className="text-right">Stock</TableHead>
|
||||
<TableHead className="text-right">Excess</TableHead>
|
||||
<TableHead className="text-right">Cost</TableHead>
|
||||
<TableHead className="text-right">Retail</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data?.map((product) => (
|
||||
<TableRow key={product.pid}>
|
||||
<TableCell>
|
||||
<a
|
||||
href={`https://backend.acherryontop.com/product/${product.pid}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:underline"
|
||||
>
|
||||
{product.title}
|
||||
</a>
|
||||
<div className="text-sm text-muted-foreground">{product.sku}</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">{product.stock_quantity}</TableCell>
|
||||
<TableCell className="text-right">{product.overstocked_amt}</TableCell>
|
||||
<TableCell className="text-right">{formatCurrency(product.excess_cost)}</TableCell>
|
||||
<TableCell className="text-right">{formatCurrency(product.excess_retail)}</TableCell>
|
||||
{isError ? (
|
||||
<p className="text-sm text-destructive">Failed to load overstocked products</p>
|
||||
) : isLoading ? (
|
||||
<TableSkeleton />
|
||||
) : (
|
||||
<ScrollArea className="h-[300px] w-full">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Product</TableHead>
|
||||
<TableHead className="text-right">Stock</TableHead>
|
||||
<TableHead className="text-right">Excess</TableHead>
|
||||
<TableHead className="text-right">Cost</TableHead>
|
||||
<TableHead className="text-right">Retail</TableHead>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data?.map((product) => (
|
||||
<TableRow key={product.pid}>
|
||||
<TableCell>
|
||||
<a
|
||||
href={`https://backend.acherryontop.com/product/${product.pid}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:underline"
|
||||
>
|
||||
{product.title}
|
||||
</a>
|
||||
<div className="text-sm text-muted-foreground">{product.sku}</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">{product.stock_quantity}</TableCell>
|
||||
<TableCell className="text-right">{product.overstocked_amt}</TableCell>
|
||||
<TableCell className="text-right">{formatCurrency(Number(product.excess_cost))}</TableCell>
|
||||
<TableCell className="text-right">{formatCurrency(Number(product.excess_retail))}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</CardContent>
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ 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 { format } from "date-fns"
|
||||
|
||||
interface Product {
|
||||
pid: number;
|
||||
@@ -14,14 +15,22 @@ interface Product {
|
||||
last_purchase_date: string | null;
|
||||
}
|
||||
|
||||
function TableSkeleton() {
|
||||
return (
|
||||
<div className="space-y-3 p-4">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="h-8 animate-pulse rounded bg-muted" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TopReplenishProducts() {
|
||||
const { data } = useQuery<Product[]>({
|
||||
const { data, isError, isLoading } = useQuery<Product[]>({
|
||||
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")
|
||||
}
|
||||
if (!response.ok) throw new Error("Failed to fetch products to replenish");
|
||||
return response.json()
|
||||
},
|
||||
})
|
||||
@@ -32,41 +41,47 @@ export function TopReplenishProducts() {
|
||||
<CardTitle className="text-xl font-medium">Top Products To Replenish</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ScrollArea className="max-h-[530px] w-full overflow-y-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Product</TableHead>
|
||||
<TableHead className="text-right">Stock</TableHead>
|
||||
<TableHead className="text-right">Daily Sales</TableHead>
|
||||
<TableHead className="text-right">Reorder Qty</TableHead>
|
||||
<TableHead>Last Purchase</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data?.map((product) => (
|
||||
<TableRow key={product.pid}>
|
||||
<TableCell>
|
||||
<a
|
||||
href={`https://backend.acherryontop.com/product/${product.pid}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:underline"
|
||||
>
|
||||
{product.title}
|
||||
</a>
|
||||
<div className="text-sm text-muted-foreground">{product.sku}</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">{product.stock_quantity}</TableCell>
|
||||
<TableCell className="text-right">{Number(product.daily_sales_avg).toFixed(1)}</TableCell>
|
||||
<TableCell className="text-right">{product.reorder_qty}</TableCell>
|
||||
<TableCell>{product.last_purchase_date ? product.last_purchase_date : '-'}</TableCell>
|
||||
{isError ? (
|
||||
<p className="text-sm text-destructive">Failed to load replenish products</p>
|
||||
) : isLoading ? (
|
||||
<TableSkeleton />
|
||||
) : (
|
||||
<ScrollArea className="max-h-[530px] w-full overflow-y-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Product</TableHead>
|
||||
<TableHead className="text-right">Stock</TableHead>
|
||||
<TableHead className="text-right">Daily Sales</TableHead>
|
||||
<TableHead className="text-right">Reorder Qty</TableHead>
|
||||
<TableHead>Last Purchase</TableHead>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data?.map((product) => (
|
||||
<TableRow key={product.pid}>
|
||||
<TableCell>
|
||||
<a
|
||||
href={`https://backend.acherryontop.com/product/${product.pid}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:underline"
|
||||
>
|
||||
{product.title}
|
||||
</a>
|
||||
<div className="text-sm text-muted-foreground">{product.sku}</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">{product.stock_quantity}</TableCell>
|
||||
<TableCell className="text-right">{Number(product.daily_sales_avg).toFixed(1)}</TableCell>
|
||||
<TableCell className="text-right">{product.reorder_qty}</TableCell>
|
||||
<TableCell className="whitespace-nowrap">{product.last_purchase_date ? format(new Date(product.last_purchase_date), 'M/dd/yyyy') : '-'}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</CardContent>
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
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
|
||||
avg_lead_time: number
|
||||
on_time_delivery_rate: number
|
||||
avg_fill_rate: number
|
||||
total_orders: number
|
||||
active_orders: number
|
||||
overdue_orders: number
|
||||
}
|
||||
|
||||
export function VendorPerformance() {
|
||||
const { data: vendors } = useQuery<VendorMetrics[]>({
|
||||
queryKey: ["vendor-metrics"],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${config.apiUrl}/dashboard/vendor/performance`)
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch vendor metrics")
|
||||
}
|
||||
return response.json()
|
||||
},
|
||||
})
|
||||
|
||||
// Sort vendors by on-time delivery rate
|
||||
const sortedVendors = vendors
|
||||
?.sort((a, b) => b.on_time_delivery_rate - a.on_time_delivery_rate)
|
||||
|
||||
return (
|
||||
<>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-medium">Top Vendor Performance</CardTitle>
|
||||
</CardHeader>
|
||||
<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.avg_fill_rate.toFixed(0)}%
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -106,7 +106,7 @@ export function Analytics() {
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Avg Stock Cover</CardTitle>
|
||||
<CardTitle className="text-sm font-medium">Median Stock Cover</CardTitle>
|
||||
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
||||
Reference in New Issue
Block a user