Refactor admin analytics stat cards to reusable component
Extracted repeated stat card logic in AdminAnalytics to a new AdminStatCard component and moved the trend indicator to its own file. Updated AdminAnalytics to use AdminStatCard for orders, revenue, vendors, and products, improving code maintainability and consistency. Also updated chart and loading skeleton handling for better UX.
This commit is contained in:
@@ -47,6 +47,14 @@ import {
|
|||||||
} from "recharts";
|
} from "recharts";
|
||||||
import { formatGBP, formatNumber } from "@/lib/utils/format";
|
import { formatGBP, formatNumber } from "@/lib/utils/format";
|
||||||
import { PieChart, Pie, Cell, Legend } from "recharts";
|
import { PieChart, Pie, Cell, Legend } from "recharts";
|
||||||
|
import { AdminStatCard } from "./AdminStatCard";
|
||||||
|
import { TrendIndicator } from "./TrendIndicator";
|
||||||
|
import {
|
||||||
|
ChartSkeleton,
|
||||||
|
CustomerInsightsSkeleton,
|
||||||
|
MetricsCardSkeleton,
|
||||||
|
TableSkeleton
|
||||||
|
} from "../analytics/SkeletonLoaders";
|
||||||
|
|
||||||
interface GrowthData {
|
interface GrowthData {
|
||||||
launchDate: string;
|
launchDate: string;
|
||||||
@@ -417,33 +425,7 @@ export default function AdminAnalytics() {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Trend indicator component for metric cards
|
|
||||||
const TrendIndicator = ({
|
|
||||||
current,
|
|
||||||
previous,
|
|
||||||
}: {
|
|
||||||
current: number;
|
|
||||||
previous: number;
|
|
||||||
}) => {
|
|
||||||
if (!current || !previous) return null;
|
|
||||||
|
|
||||||
const percentChange = ((current - previous) / previous) * 100;
|
|
||||||
|
|
||||||
if (Math.abs(percentChange) < 0.1) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`flex items-center text-xs font-medium ${percentChange >= 0 ? "text-green-500" : "text-red-500"}`}
|
|
||||||
>
|
|
||||||
{percentChange >= 0 ? (
|
|
||||||
<TrendingUp className="h-3 w-3 mr-1" />
|
|
||||||
) : (
|
|
||||||
<TrendingDown className="h-3 w-3 mr-1" />
|
|
||||||
)}
|
|
||||||
{Math.abs(percentChange).toFixed(1)}%
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Format currency
|
// Format currency
|
||||||
const formatCurrency = (value: number) => {
|
const formatCurrency = (value: number) => {
|
||||||
@@ -707,234 +689,98 @@ export default function AdminAnalytics() {
|
|||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
{/* Orders Card */}
|
{/* Orders Card */}
|
||||||
<Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm overflow-hidden hover:shadow-md transition-shadow duration-300">
|
<AdminStatCard
|
||||||
<CardHeader className="pb-2">
|
title="Total Orders"
|
||||||
<div className="flex justify-between items-start">
|
icon={ShoppingCart}
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
iconColorClass="text-blue-500"
|
||||||
Total Orders
|
iconBgClass="bg-blue-500/10"
|
||||||
</CardTitle>
|
value={formatNumber(analyticsData?.orders?.total)}
|
||||||
<div className="p-2 bg-blue-500/10 rounded-md">
|
subtext={
|
||||||
<ShoppingCart className="h-4 w-4 text-blue-500" />
|
<span className="bg-muted/50 px-1.5 py-0.5 rounded">
|
||||||
</div>
|
Today: {analyticsData?.orders?.totalToday || 0}
|
||||||
</div>
|
</span>
|
||||||
</CardHeader>
|
}
|
||||||
<CardContent>
|
trend={{
|
||||||
<div className="text-2xl font-bold">
|
current: analyticsData?.orders?.totalToday || 0,
|
||||||
{formatNumber(analyticsData?.orders?.total)}
|
previous: (analyticsData?.orders?.total || 0) / 30, // Approx simple moving average
|
||||||
</div>
|
}}
|
||||||
<div className="flex items-center text-xs text-muted-foreground mt-1">
|
loading={loading || refreshing}
|
||||||
<span className="bg-muted/50 px-1.5 py-0.5 rounded">Today: {analyticsData?.orders?.totalToday || 0}</span>
|
chartData={transformChartData(
|
||||||
<div className="ml-auto">
|
analyticsData?.orders?.dailyOrders || [],
|
||||||
<TrendIndicator
|
"count"
|
||||||
current={analyticsData?.orders?.totalToday || 0}
|
)}
|
||||||
previous={(analyticsData?.orders?.total || 0) / 30}
|
chartColor="#3b82f6"
|
||||||
/>
|
chartGradientId="colorOrdersStat"
|
||||||
</div>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading || refreshing ? (
|
|
||||||
<div className="mt-4 h-14 flex items-center justify-center">
|
|
||||||
<div className="animate-spin h-4 w-4 border-2 border-primary border-t-transparent rounded-full"></div>
|
|
||||||
</div>
|
|
||||||
) : analyticsData?.orders?.dailyOrders &&
|
|
||||||
analyticsData.orders.dailyOrders.length > 0 ? (
|
|
||||||
<div className="mt-4 h-14 -mx-2">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<AreaChart
|
|
||||||
data={transformChartData(
|
|
||||||
analyticsData.orders.dailyOrders,
|
|
||||||
"count",
|
|
||||||
)}
|
|
||||||
margin={{ top: 5, right: 0, left: 0, bottom: 0 }}
|
|
||||||
>
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="colorOrdersAdmin" x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3} />
|
|
||||||
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<Area
|
|
||||||
type="monotone"
|
|
||||||
dataKey="value"
|
|
||||||
stroke="#3b82f6"
|
|
||||||
fillOpacity={1}
|
|
||||||
fill="url(#colorOrdersAdmin)"
|
|
||||||
strokeWidth={2}
|
|
||||||
activeDot={{ r: 4, strokeWidth: 0, fill: "#3b82f6" }}
|
|
||||||
/>
|
|
||||||
<Tooltip content={<CustomTooltip />} cursor={{ stroke: 'rgba(255,255,255,0.1)', strokeWidth: 1 }} />
|
|
||||||
</AreaChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="mt-4 h-14 flex items-center justify-center text-xs text-muted-foreground bg-muted/20 rounded">
|
|
||||||
No chart data
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Revenue Card */}
|
{/* Revenue Card */}
|
||||||
<Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm overflow-hidden hover:shadow-md transition-shadow duration-300">
|
<AdminStatCard
|
||||||
<CardHeader className="pb-2">
|
title="Total Revenue"
|
||||||
<div className="flex justify-between items-start">
|
icon={DollarSign}
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
iconColorClass="text-green-500"
|
||||||
Total Revenue
|
iconBgClass="bg-green-500/10"
|
||||||
</CardTitle>
|
value={formatCurrency(analyticsData?.revenue?.total || 0)}
|
||||||
<div className="p-2 bg-green-500/10 rounded-md">
|
subtext={
|
||||||
<DollarSign className="h-4 w-4 text-green-500" />
|
<span className="bg-muted/50 px-1.5 py-0.5 rounded">
|
||||||
</div>
|
Today: {formatCurrency(analyticsData?.revenue?.today || 0)}
|
||||||
</div>
|
</span>
|
||||||
</CardHeader>
|
}
|
||||||
<CardContent>
|
trend={{
|
||||||
<div className="text-2xl font-bold">
|
current: analyticsData?.revenue?.today || 0,
|
||||||
{formatCurrency(analyticsData?.revenue?.total || 0)}
|
previous: (analyticsData?.revenue?.total || 0) / 30,
|
||||||
</div>
|
}}
|
||||||
<div className="flex items-center text-xs text-muted-foreground mt-1">
|
loading={loading || refreshing}
|
||||||
<span className="bg-muted/50 px-1.5 py-0.5 rounded">Today: {formatCurrency(analyticsData?.revenue?.today || 0)}</span>
|
chartData={transformChartData(
|
||||||
<div className="ml-auto">
|
analyticsData?.revenue?.dailyRevenue || [],
|
||||||
<TrendIndicator
|
"amount"
|
||||||
current={analyticsData?.revenue?.today || 0}
|
)}
|
||||||
previous={(analyticsData?.revenue?.total || 0) / 30}
|
chartColor="#10b981"
|
||||||
/>
|
chartGradientId="colorRevenueStat"
|
||||||
</div>
|
tooltipPrefix="£"
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
{loading || refreshing ? (
|
|
||||||
<div className="mt-4 h-14 flex items-center justify-center">
|
|
||||||
<div className="animate-spin h-4 w-4 border-2 border-primary border-t-transparent rounded-full"></div>
|
|
||||||
</div>
|
|
||||||
) : analyticsData?.revenue?.dailyRevenue &&
|
|
||||||
analyticsData.revenue.dailyRevenue.length > 0 ? (
|
|
||||||
<div className="mt-4 h-14 -mx-2">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<AreaChart
|
|
||||||
data={transformChartData(
|
|
||||||
analyticsData.revenue.dailyRevenue,
|
|
||||||
"amount",
|
|
||||||
)}
|
|
||||||
margin={{ top: 5, right: 0, left: 0, bottom: 0 }}
|
|
||||||
>
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="colorRevenueAdmin" x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop offset="5%" stopColor="#10b981" stopOpacity={0.3} />
|
|
||||||
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<Area
|
|
||||||
type="monotone"
|
|
||||||
dataKey="value"
|
|
||||||
stroke="#10b981"
|
|
||||||
fillOpacity={1}
|
|
||||||
fill="url(#colorRevenueAdmin)"
|
|
||||||
strokeWidth={2}
|
|
||||||
activeDot={{ r: 4, strokeWidth: 0, fill: "#10b981" }}
|
|
||||||
/>
|
|
||||||
<Tooltip content={<CustomTooltip />} cursor={{ stroke: 'rgba(255,255,255,0.1)', strokeWidth: 1 }} />
|
|
||||||
</AreaChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="mt-4 h-14 flex items-center justify-center text-xs text-muted-foreground bg-muted/20 rounded">
|
|
||||||
No chart data
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Vendors Card */}
|
{/* Vendors Card */}
|
||||||
<Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm overflow-hidden hover:shadow-md transition-shadow duration-300">
|
<AdminStatCard
|
||||||
<CardHeader className="pb-2">
|
title="Vendors"
|
||||||
<div className="flex justify-between items-start">
|
icon={Users}
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">Vendors</CardTitle>
|
iconColorClass="text-purple-500"
|
||||||
<div className="p-2 bg-purple-500/10 rounded-md">
|
iconBgClass="bg-purple-500/10"
|
||||||
<Users className="h-4 w-4 text-purple-500" />
|
value={analyticsData?.vendors?.total?.toLocaleString() || "0"}
|
||||||
</div>
|
subtext={<span>New: {analyticsData?.vendors?.newToday || 0}</span>}
|
||||||
</div>
|
trend={{
|
||||||
</CardHeader>
|
current: analyticsData?.vendors?.newToday || 0,
|
||||||
<CardContent>
|
previous: (analyticsData?.vendors?.newThisWeek || 0) / 7,
|
||||||
<div className="text-2xl font-bold">
|
}}
|
||||||
{analyticsData?.vendors?.total?.toLocaleString() || "0"}
|
loading={loading || refreshing}
|
||||||
</div>
|
chartData={transformChartData(
|
||||||
<div className="flex items-center text-xs text-muted-foreground mt-1 gap-2">
|
analyticsData?.vendors?.dailyGrowth || [],
|
||||||
<span className="bg-muted/50 px-1.5 py-0.5 rounded">Active: {analyticsData?.vendors?.active || 0}</span>
|
"count"
|
||||||
<span className="bg-muted/50 px-1.5 py-0.5 rounded">Stores: {analyticsData?.vendors?.activeStores || 0}</span>
|
)}
|
||||||
</div>
|
chartColor="#8b5cf6"
|
||||||
<div className="flex items-center text-xs text-muted-foreground mt-2">
|
chartGradientId="colorVendorsStat"
|
||||||
<span>New: {analyticsData?.vendors?.newToday || 0}</span>
|
>
|
||||||
<div className="ml-auto">
|
<div className="flex items-center text-xs text-muted-foreground gap-2">
|
||||||
<TrendIndicator
|
<span className="bg-muted/50 px-1.5 py-0.5 rounded">
|
||||||
current={analyticsData?.vendors?.newToday || 0}
|
Active: {analyticsData?.vendors?.active || 0}
|
||||||
previous={(analyticsData?.vendors?.newThisWeek || 0) / 7}
|
</span>
|
||||||
/>
|
<span className="bg-muted/50 px-1.5 py-0.5 rounded">
|
||||||
</div>
|
Stores: {analyticsData?.vendors?.activeStores || 0}
|
||||||
</div>
|
</span>
|
||||||
|
</div>
|
||||||
{loading || refreshing ? (
|
</AdminStatCard>
|
||||||
<div className="mt-2 h-12 flex items-center justify-center">
|
|
||||||
<div className="animate-spin h-4 w-4 border-2 border-primary border-t-transparent rounded-full"></div>
|
|
||||||
</div>
|
|
||||||
) : analyticsData?.vendors?.dailyGrowth &&
|
|
||||||
analyticsData.vendors.dailyGrowth.length > 0 ? (
|
|
||||||
<div className="mt-2 h-12 -mx-2">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<AreaChart
|
|
||||||
data={transformChartData(
|
|
||||||
analyticsData.vendors.dailyGrowth,
|
|
||||||
"count",
|
|
||||||
)}
|
|
||||||
margin={{ top: 5, right: 0, left: 0, bottom: 0 }}
|
|
||||||
>
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="colorVendorsAdmin" x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop offset="5%" stopColor="#8b5cf6" stopOpacity={0.3} />
|
|
||||||
<stop offset="95%" stopColor="#8b5cf6" stopOpacity={0} />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<Area
|
|
||||||
type="monotone"
|
|
||||||
dataKey="value"
|
|
||||||
stroke="#8b5cf6"
|
|
||||||
fillOpacity={1}
|
|
||||||
fill="url(#colorVendorsAdmin)"
|
|
||||||
strokeWidth={2}
|
|
||||||
activeDot={{ r: 4, strokeWidth: 0, fill: "#8b5cf6" }}
|
|
||||||
/>
|
|
||||||
<Tooltip content={<CustomTooltip />} cursor={{ stroke: 'rgba(255,255,255,0.1)', strokeWidth: 1 }} />
|
|
||||||
</AreaChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="mt-2 h-12 flex items-center justify-center text-xs text-muted-foreground bg-muted/20 rounded">
|
|
||||||
No chart data
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Products Card */}
|
{/* Products Card */}
|
||||||
<Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm overflow-hidden hover:shadow-md transition-shadow duration-300">
|
<AdminStatCard
|
||||||
<CardHeader className="pb-2">
|
title="Products"
|
||||||
<div className="flex justify-between items-start">
|
icon={Package}
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">Products</CardTitle>
|
iconColorClass="text-amber-500"
|
||||||
<div className="p-2 bg-amber-500/10 rounded-md">
|
iconBgClass="bg-amber-500/10"
|
||||||
<Package className="h-4 w-4 text-amber-500" />
|
value={formatNumber(analyticsData?.products?.total)}
|
||||||
</div>
|
loading={loading || refreshing}
|
||||||
</div>
|
chartColor="#f59e0b"
|
||||||
</CardHeader>
|
chartGradientId="colorProductsStat"
|
||||||
<CardContent>
|
hideChart={true}
|
||||||
<div className="text-2xl font-bold">
|
/>
|
||||||
{formatNumber(analyticsData?.products?.total)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center text-xs text-muted-foreground mt-1">
|
|
||||||
<span className="bg-muted/50 px-1.5 py-0.5 rounded">New This Week: {analyticsData?.products?.recent || 0}</span>
|
|
||||||
</div>
|
|
||||||
{/* Visual spacer since no chart here */}
|
|
||||||
<div className="mt-4 h-14 w-full bg-gradient-to-r from-amber-500/5 to-transparent rounded-md flex items-center justify-center">
|
|
||||||
<span className="text-xs text-muted-foreground/50 italic">Inventory Overview</span>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs defaultValue="orders" className="mt-8">
|
<Tabs defaultValue="orders" className="mt-8">
|
||||||
@@ -960,251 +806,290 @@ export default function AdminAnalytics() {
|
|||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="orders" className="mt-4 animate-in fade-in slide-in-from-bottom-2 duration-500">
|
<TabsContent value="orders" className="mt-4 animate-in fade-in slide-in-from-bottom-2 duration-500">
|
||||||
<Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm overflow-hidden">
|
{loading || refreshing ? (
|
||||||
<CardHeader>
|
<ChartSkeleton
|
||||||
<CardTitle className="bg-gradient-to-r from-blue-600 to-cyan-500 bg-clip-text text-transparent w-fit">Order Trends</CardTitle>
|
title="Order Trends"
|
||||||
<CardDescription>
|
description="Daily order volume and revenue processed over the selected time period"
|
||||||
Daily order volume and revenue processed over the selected time period
|
icon={BarChart}
|
||||||
</CardDescription>
|
showStats={true}
|
||||||
</CardHeader>
|
/>
|
||||||
<CardContent>
|
) : (
|
||||||
{loading || refreshing ? (
|
<Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm overflow-hidden">
|
||||||
<div className="flex items-center justify-center h-80">
|
<CardHeader>
|
||||||
<div className="flex flex-col items-center gap-2">
|
<CardTitle className="bg-gradient-to-r from-blue-600 to-cyan-500 bg-clip-text text-transparent w-fit">
|
||||||
<div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full"></div>
|
Order Trends
|
||||||
<p className="text-sm text-muted-foreground">
|
</CardTitle>
|
||||||
Loading chart data...
|
<CardDescription>
|
||||||
</p>
|
Daily order volume and revenue processed over the selected time
|
||||||
</div>
|
period
|
||||||
</div>
|
</CardDescription>
|
||||||
) : analyticsData?.orders?.dailyOrders &&
|
</CardHeader>
|
||||||
analyticsData.orders.dailyOrders.length > 0 ? (
|
<CardContent>
|
||||||
<div className="h-80">
|
{analyticsData?.orders?.dailyOrders &&
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
analyticsData.orders.dailyOrders.length > 0 ? (
|
||||||
<ComposedChart
|
<div className="h-80">
|
||||||
data={combineOrdersAndRevenue(
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
analyticsData.orders.dailyOrders,
|
<ComposedChart
|
||||||
analyticsData.revenue?.dailyRevenue || [],
|
data={combineOrdersAndRevenue(
|
||||||
)}
|
analyticsData.orders.dailyOrders,
|
||||||
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
analyticsData.revenue?.dailyRevenue || [],
|
||||||
>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
|
||||||
<XAxis
|
|
||||||
dataKey="formattedDate"
|
|
||||||
tick={{ fontSize: 12 }}
|
|
||||||
angle={-45}
|
|
||||||
textAnchor="end"
|
|
||||||
height={60}
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
yAxisId="left"
|
|
||||||
tick={{ fontSize: 12 }}
|
|
||||||
label={{
|
|
||||||
value: "Orders",
|
|
||||||
angle: -90,
|
|
||||||
position: "insideLeft",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
yAxisId="right"
|
|
||||||
orientation="right"
|
|
||||||
tick={{ fontSize: 12 }}
|
|
||||||
tickFormatter={(value) =>
|
|
||||||
`£${(value / 1000).toFixed(0)}k`
|
|
||||||
}
|
|
||||||
label={{
|
|
||||||
value: "Revenue / AOV",
|
|
||||||
angle: 90,
|
|
||||||
position: "insideRight",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Tooltip content={<CustomTooltip />} />
|
|
||||||
<Bar
|
|
||||||
yAxisId="left"
|
|
||||||
dataKey="orders"
|
|
||||||
fill="#3b82f6"
|
|
||||||
radius={[2, 2, 0, 0]}
|
|
||||||
name="Orders"
|
|
||||||
/>
|
|
||||||
<Line
|
|
||||||
yAxisId="right"
|
|
||||||
type="monotone"
|
|
||||||
dataKey="revenue"
|
|
||||||
stroke="#10b981"
|
|
||||||
strokeWidth={2}
|
|
||||||
dot={{ fill: "#10b981", r: 4 }}
|
|
||||||
name="Revenue"
|
|
||||||
/>
|
|
||||||
<Line
|
|
||||||
yAxisId="right"
|
|
||||||
type="monotone"
|
|
||||||
dataKey="avgOrderValue"
|
|
||||||
stroke="#a855f7"
|
|
||||||
strokeWidth={2}
|
|
||||||
strokeDasharray="5 5"
|
|
||||||
dot={{ fill: "#a855f7", r: 3 }}
|
|
||||||
name="Avg Order Value"
|
|
||||||
/>
|
|
||||||
</ComposedChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center justify-center h-[300px] text-muted-foreground">
|
|
||||||
No order data available for the selected time period
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Calculate totals for the selected period */}
|
|
||||||
{analyticsData?.orders?.dailyOrders &&
|
|
||||||
analyticsData?.revenue?.dailyRevenue && (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-6">
|
|
||||||
<div className="bg-muted/50 p-4 rounded-lg">
|
|
||||||
<div className="text-sm font-medium mb-1">
|
|
||||||
Total Revenue
|
|
||||||
</div>
|
|
||||||
<div className="text-2xl font-bold text-green-600">
|
|
||||||
{formatCurrency(
|
|
||||||
analyticsData.revenue.dailyRevenue.reduce(
|
|
||||||
(sum, day) => sum + (day.amount || 0),
|
|
||||||
0,
|
|
||||||
),
|
|
||||||
)}
|
)}
|
||||||
</div>
|
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
||||||
</div>
|
>
|
||||||
<div className="bg-muted/50 p-4 rounded-lg">
|
<defs>
|
||||||
<div className="text-sm font-medium mb-1">
|
<linearGradient id="colorOrdersTrends" x1="0" y1="0" x2="0" y2="1">
|
||||||
Total Orders
|
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3} />
|
||||||
</div>
|
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
|
||||||
<div className="text-2xl font-bold text-blue-600">
|
</linearGradient>
|
||||||
{analyticsData.orders.dailyOrders
|
<linearGradient id="colorRevenueTrends" x1="0" y1="0" x2="0" y2="1">
|
||||||
.reduce((sum, day) => sum + (day.count || 0), 0)
|
<stop offset="5%" stopColor="#10b981" stopOpacity={0.3} />
|
||||||
.toLocaleString()}
|
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
|
||||||
</div>
|
</linearGradient>
|
||||||
</div>
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="hsl(var(--border))" opacity={0.4} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="formattedDate"
|
||||||
|
tick={{ fontSize: 12, fill: 'hsl(var(--muted-foreground))' }}
|
||||||
|
angle={-45}
|
||||||
|
textAnchor="end"
|
||||||
|
height={60}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
yAxisId="left"
|
||||||
|
tick={{ fontSize: 12, fill: 'hsl(var(--muted-foreground))' }}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
label={{
|
||||||
|
value: "Orders",
|
||||||
|
angle: -90,
|
||||||
|
position: "insideLeft",
|
||||||
|
style: { fill: 'hsl(var(--muted-foreground))' }
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
yAxisId="right"
|
||||||
|
orientation="right"
|
||||||
|
tick={{ fontSize: 12, fill: 'hsl(var(--muted-foreground))' }}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
tickFormatter={(value) =>
|
||||||
|
`£${(value / 1000).toFixed(0)}k`
|
||||||
|
}
|
||||||
|
label={{
|
||||||
|
value: "Revenue",
|
||||||
|
angle: 90,
|
||||||
|
position: "insideRight",
|
||||||
|
style: { fill: 'hsl(var(--muted-foreground))' }
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tooltip content={<CustomTooltip />} cursor={{ stroke: 'rgba(255,255,255,0.1)', strokeWidth: 1 }} />
|
||||||
|
<Area
|
||||||
|
yAxisId="left"
|
||||||
|
type="monotone"
|
||||||
|
dataKey="orders"
|
||||||
|
stroke="#3b82f6"
|
||||||
|
fill="url(#colorOrdersTrends)"
|
||||||
|
strokeWidth={2}
|
||||||
|
name="Orders"
|
||||||
|
activeDot={{ r: 4, strokeWidth: 0, fill: "#3b82f6" }}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
yAxisId="right"
|
||||||
|
type="monotone"
|
||||||
|
dataKey="revenue"
|
||||||
|
stroke="#10b981"
|
||||||
|
fill="url(#colorRevenueTrends)"
|
||||||
|
strokeWidth={2}
|
||||||
|
name="Revenue"
|
||||||
|
activeDot={{ r: 4, strokeWidth: 0, fill: "#10b981" }}
|
||||||
|
/>
|
||||||
|
</ComposedChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-[300px] text-muted-foreground">
|
||||||
|
No order data available for the selected time period
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
|
||||||
</Card>
|
{/* Calculate totals for the selected period */}
|
||||||
|
{analyticsData?.orders?.dailyOrders &&
|
||||||
|
analyticsData?.revenue?.dailyRevenue && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-6">
|
||||||
|
<div className="bg-muted/50 p-4 rounded-lg">
|
||||||
|
<div className="text-sm font-medium mb-1">
|
||||||
|
Total Revenue
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold text-green-600">
|
||||||
|
{formatCurrency(
|
||||||
|
analyticsData.revenue.dailyRevenue.reduce(
|
||||||
|
(sum, day) => sum + (day.amount || 0),
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-muted/50 p-4 rounded-lg">
|
||||||
|
<div className="text-sm font-medium mb-1">
|
||||||
|
Total Orders
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold text-blue-600">
|
||||||
|
{analyticsData.orders.dailyOrders
|
||||||
|
.reduce((sum, day) => sum + (day.count || 0), 0)
|
||||||
|
.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="vendors" className="mt-4 animate-in fade-in slide-in-from-bottom-2 duration-500">
|
<TabsContent value="vendors" className="mt-4 animate-in fade-in slide-in-from-bottom-2 duration-500">
|
||||||
<Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm overflow-hidden">
|
{loading || refreshing ? (
|
||||||
<CardHeader>
|
<ChartSkeleton
|
||||||
<CardTitle className="bg-gradient-to-r from-purple-600 to-pink-500 bg-clip-text text-transparent w-fit">Vendor Growth</CardTitle>
|
title="Vendor Growth"
|
||||||
<CardDescription>
|
description="New vendor registrations over time"
|
||||||
New vendor registrations over time
|
icon={Users}
|
||||||
</CardDescription>
|
showStats={true}
|
||||||
</CardHeader>
|
/>
|
||||||
<CardContent>
|
) : (
|
||||||
{loading || refreshing ? (
|
<Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm overflow-hidden">
|
||||||
<div className="flex items-center justify-center h-80">
|
<CardHeader>
|
||||||
<div className="flex flex-col items-center gap-2">
|
<CardTitle className="bg-gradient-to-r from-purple-600 to-pink-500 bg-clip-text text-transparent w-fit">
|
||||||
<div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full"></div>
|
Vendor Growth
|
||||||
<p className="text-sm text-muted-foreground">
|
</CardTitle>
|
||||||
Loading chart data...
|
<CardDescription>New vendor registrations over time</CardDescription>
|
||||||
</p>
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{analyticsData?.vendors?.dailyGrowth &&
|
||||||
|
analyticsData.vendors.dailyGrowth.length > 0 ? (
|
||||||
|
<div className="h-80">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<AreaChart
|
||||||
|
data={transformChartData(
|
||||||
|
analyticsData.vendors.dailyGrowth,
|
||||||
|
"count",
|
||||||
|
)}
|
||||||
|
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<linearGradient
|
||||||
|
id="colorVendorsAdminChart"
|
||||||
|
x1="0"
|
||||||
|
y1="0"
|
||||||
|
x2="0"
|
||||||
|
y2="1"
|
||||||
|
>
|
||||||
|
<stop
|
||||||
|
offset="5%"
|
||||||
|
stopColor="#8b5cf6"
|
||||||
|
stopOpacity={0.3}
|
||||||
|
/>
|
||||||
|
<stop
|
||||||
|
offset="95%"
|
||||||
|
stopColor="#8b5cf6"
|
||||||
|
stopOpacity={0}
|
||||||
|
/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="value"
|
||||||
|
stroke="#8b5cf6"
|
||||||
|
fillOpacity={1}
|
||||||
|
fill="url(#colorVendorsAdminChart)"
|
||||||
|
strokeWidth={2}
|
||||||
|
activeDot={{ r: 4, strokeWidth: 0, fill: "#8b5cf6" }}
|
||||||
|
/>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="formattedDate"
|
||||||
|
tick={{ fontSize: 12 }}
|
||||||
|
angle={-45}
|
||||||
|
textAnchor="end"
|
||||||
|
height={60}
|
||||||
|
/>
|
||||||
|
<YAxis tick={{ fontSize: 12 }} />
|
||||||
|
<Tooltip content={<CustomTooltip />} />
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
) : analyticsData?.vendors?.dailyGrowth &&
|
<div className="flex items-center justify-center h-[300px] text-muted-foreground">
|
||||||
analyticsData.vendors.dailyGrowth.length > 0 ? (
|
No vendor data available for the selected time period
|
||||||
<div className="h-80">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<RechartsBarChart
|
|
||||||
data={transformChartData(
|
|
||||||
analyticsData.vendors.dailyGrowth,
|
|
||||||
"count",
|
|
||||||
)}
|
|
||||||
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
|
||||||
>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
|
||||||
<XAxis
|
|
||||||
dataKey="formattedDate"
|
|
||||||
tick={{ fontSize: 12 }}
|
|
||||||
angle={-45}
|
|
||||||
textAnchor="end"
|
|
||||||
height={60}
|
|
||||||
/>
|
|
||||||
<YAxis tick={{ fontSize: 12 }} />
|
|
||||||
<Tooltip content={<CustomTooltip />} />
|
|
||||||
<Bar
|
|
||||||
dataKey="value"
|
|
||||||
fill="#8b5cf6"
|
|
||||||
radius={[2, 2, 0, 0]}
|
|
||||||
/>
|
|
||||||
</RechartsBarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center justify-center h-[300px] text-muted-foreground">
|
|
||||||
No vendor data available for the selected time period
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mt-6">
|
|
||||||
<div className="bg-muted/50 p-4 rounded-lg">
|
|
||||||
<div className="text-sm font-medium mb-1">Total Vendors</div>
|
|
||||||
<div className="text-2xl font-bold">
|
|
||||||
{formatNumber(analyticsData?.vendors?.total)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-muted/50 p-4 rounded-lg">
|
|
||||||
<div className="text-sm font-medium mb-1">Active Vendors</div>
|
|
||||||
<div className="text-2xl font-bold">
|
|
||||||
{formatNumber(analyticsData?.vendors?.active)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-muted/50 p-4 rounded-lg">
|
|
||||||
<div className="text-sm font-medium mb-1">Active Stores</div>
|
|
||||||
<div className="text-2xl font-bold">
|
|
||||||
{formatNumber(analyticsData?.vendors?.activeStores)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-muted/50 p-4 rounded-lg">
|
|
||||||
<div className="text-sm font-medium mb-1">New This Week</div>
|
|
||||||
<div className="text-2xl font-bold">
|
|
||||||
{formatNumber(analyticsData?.vendors?.newThisWeek)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Top Vendors by Revenue */}
|
|
||||||
{analyticsData?.vendors?.topVendors &&
|
|
||||||
analyticsData.vendors.topVendors.length > 0 && (
|
|
||||||
<div className="mt-6">
|
|
||||||
<h3 className="text-lg font-semibold mb-4">
|
|
||||||
Top Vendors by Revenue
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{analyticsData.vendors.topVendors.map((vendor, index) => (
|
|
||||||
<div
|
|
||||||
key={vendor.vendorId}
|
|
||||||
className="flex items-center justify-between p-3 bg-muted/30 rounded-lg"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-primary/10 text-primary font-semibold text-sm">
|
|
||||||
{index + 1}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="font-medium">
|
|
||||||
{vendor.vendorName}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
{vendor.orderCount} orders
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<div className="font-semibold text-green-600">
|
|
||||||
{formatCurrency(vendor.totalRevenue)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
|
||||||
</Card>
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mt-6">
|
||||||
|
<div className="bg-muted/50 p-4 rounded-lg">
|
||||||
|
<div className="text-sm font-medium mb-1">Total Vendors</div>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{formatNumber(analyticsData?.vendors?.total)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-muted/50 p-4 rounded-lg">
|
||||||
|
<div className="text-sm font-medium mb-1">Active Vendors</div>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{formatNumber(analyticsData?.vendors?.active)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-muted/50 p-4 rounded-lg">
|
||||||
|
<div className="text-sm font-medium mb-1">Active Stores</div>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{formatNumber(analyticsData?.vendors?.activeStores)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-muted/50 p-4 rounded-lg">
|
||||||
|
<div className="text-sm font-medium mb-1">New This Week</div>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{formatNumber(analyticsData?.vendors?.newThisWeek)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Top Vendors by Revenue */}
|
||||||
|
{analyticsData?.vendors?.topVendors &&
|
||||||
|
analyticsData.vendors.topVendors.length > 0 && (
|
||||||
|
<div className="mt-6">
|
||||||
|
<h3 className="text-lg font-semibold mb-4">
|
||||||
|
Top Vendors by Revenue
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{analyticsData.vendors.topVendors.map((vendor, index) => (
|
||||||
|
<div
|
||||||
|
key={vendor.vendorId}
|
||||||
|
className="flex items-center justify-between p-3 bg-muted/30 rounded-lg"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-primary/10 text-primary font-semibold text-sm">
|
||||||
|
{index + 1}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">
|
||||||
|
{vendor.vendorName}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{vendor.orderCount} orders
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="font-semibold text-green-600">
|
||||||
|
{formatCurrency(vendor.totalRevenue)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="growth" className="mt-4 space-y-6">
|
<TabsContent value="growth" className="mt-4 space-y-6">
|
||||||
|
|||||||
166
components/admin/AdminStatCard.tsx
Normal file
166
components/admin/AdminStatCard.tsx
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/common/card";
|
||||||
|
import { Skeleton } from "@/components/common/skeleton";
|
||||||
|
import { Area, AreaChart, ResponsiveContainer, Tooltip, TooltipProps } from "recharts";
|
||||||
|
import { TrendIndicator } from "./TrendIndicator";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { LucideIcon } from "lucide-react";
|
||||||
|
import { formatGBP } from "@/lib/utils/format";
|
||||||
|
|
||||||
|
interface ChartDataPoint {
|
||||||
|
formattedDate: string;
|
||||||
|
value: number;
|
||||||
|
orders?: number;
|
||||||
|
revenue?: number;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AdminStatCardProps {
|
||||||
|
title: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
iconColorClass: string;
|
||||||
|
iconBgClass: string;
|
||||||
|
value: string | number;
|
||||||
|
subtext?: React.ReactNode;
|
||||||
|
trend?: {
|
||||||
|
current: number;
|
||||||
|
previous: number;
|
||||||
|
};
|
||||||
|
loading?: boolean;
|
||||||
|
chartData?: ChartDataPoint[];
|
||||||
|
chartColor: string;
|
||||||
|
chartGradientId: string;
|
||||||
|
tooltipPrefix?: string; // "£" or ""
|
||||||
|
hideChart?: boolean;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CustomTooltip = ({ active, payload, label, prefix = "" }: TooltipProps<any, any> & { prefix?: string }) => {
|
||||||
|
if (active && payload && payload.length) {
|
||||||
|
const data = payload[0].payload;
|
||||||
|
return (
|
||||||
|
<div className="bg-[#050505]/90 p-3 rounded-lg shadow-xl border border-white/10 backdrop-blur-md ring-1 ring-white/5">
|
||||||
|
<p className="font-bold text-[10px] uppercase tracking-wide text-muted-foreground mb-2 border-b border-white/5 pb-1">
|
||||||
|
{data.formattedDate || label}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<span className="text-[11px] font-semibold text-primary">
|
||||||
|
{prefix === "£" ? "Revenue" : "Count"}
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] font-bold text-foreground tabular-nums">
|
||||||
|
{prefix}{prefix === "£" ? (data.value || 0).toFixed(2) : data.value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AdminStatCard({
|
||||||
|
title,
|
||||||
|
icon: Icon,
|
||||||
|
iconColorClass,
|
||||||
|
iconBgClass,
|
||||||
|
value,
|
||||||
|
subtext,
|
||||||
|
trend,
|
||||||
|
loading,
|
||||||
|
chartData,
|
||||||
|
chartColor,
|
||||||
|
chartGradientId,
|
||||||
|
tooltipPrefix = "",
|
||||||
|
hideChart = false,
|
||||||
|
children,
|
||||||
|
}: AdminStatCardProps) {
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm overflow-hidden h-full">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
<Skeleton className="h-8 w-8 rounded-md" />
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Skeleton className="h-8 w-32 mb-2" />
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<Skeleton className="h-4 w-20" />
|
||||||
|
<Skeleton className="h-4 w-12 ml-auto" />
|
||||||
|
</div>
|
||||||
|
{!hideChart && <Skeleton className="h-14 w-full rounded-md" />}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm overflow-hidden hover:shadow-md transition-shadow duration-300 h-full flex flex-col">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
|
{title}
|
||||||
|
</CardTitle>
|
||||||
|
<div className={cn("p-2 rounded-md", iconBgClass)}>
|
||||||
|
<Icon className={cn("h-4 w-4", iconColorClass)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex-1 flex flex-col">
|
||||||
|
<div className="text-2xl font-bold">{value}</div>
|
||||||
|
|
||||||
|
<div className="flex items-center text-xs text-muted-foreground mt-1">
|
||||||
|
{subtext}
|
||||||
|
{trend && (
|
||||||
|
<div className="ml-auto">
|
||||||
|
<TrendIndicator current={trend.current} previous={trend.previous} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{children && <div className="mt-2">{children}</div>}
|
||||||
|
|
||||||
|
{!hideChart && (
|
||||||
|
chartData && chartData.length > 0 ? (
|
||||||
|
<div className="mt-auto pt-4 h-[72px] -mx-2">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<AreaChart
|
||||||
|
data={chartData}
|
||||||
|
margin={{ top: 5, right: 0, left: 0, bottom: 0 }}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id={chartGradientId} x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor={chartColor} stopOpacity={0.3} />
|
||||||
|
<stop offset="95%" stopColor={chartColor} stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="value"
|
||||||
|
stroke={chartColor}
|
||||||
|
fillOpacity={1}
|
||||||
|
fill={`url(#${chartGradientId})`}
|
||||||
|
strokeWidth={2}
|
||||||
|
activeDot={{ r: 4, strokeWidth: 0, fill: chartColor }}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
content={<CustomTooltip prefix={tooltipPrefix} />}
|
||||||
|
cursor={{ stroke: 'rgba(255,255,255,0.1)', strokeWidth: 1 }}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-4 h-14 flex items-center justify-center text-xs text-muted-foreground bg-muted/20 rounded">
|
||||||
|
No chart data
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Fill space if chart is hidden but we want structure consistency */}
|
||||||
|
{hideChart && <div className="mt-auto pt-4" />}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
components/admin/TrendIndicator.tsx
Normal file
31
components/admin/TrendIndicator.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { TrendingDown, TrendingUp } from "lucide-react";
|
||||||
|
|
||||||
|
// Trend indicator component for metric cards
|
||||||
|
export const TrendIndicator = ({
|
||||||
|
current,
|
||||||
|
previous,
|
||||||
|
}: {
|
||||||
|
current: number;
|
||||||
|
previous: number;
|
||||||
|
}) => {
|
||||||
|
if (!current || !previous) return null;
|
||||||
|
|
||||||
|
const percentChange = ((current - previous) / previous) * 100;
|
||||||
|
|
||||||
|
if (Math.abs(percentChange) < 0.1) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex items-center text-xs font-medium ${percentChange >= 0 ? "text-green-500" : "text-red-500"}`}
|
||||||
|
>
|
||||||
|
{percentChange >= 0 ? (
|
||||||
|
<TrendingUp className="h-3 w-3 mr-1" />
|
||||||
|
) : (
|
||||||
|
<TrendingDown className="h-3 w-3 mr-1" />
|
||||||
|
)}
|
||||||
|
{Math.abs(percentChange).toFixed(1)}%
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"commitHash": "4c15f43",
|
"commitHash": "a07ca55",
|
||||||
"buildTime": "2026-01-13T05:27:43.269Z"
|
"buildTime": "2026-01-13T05:50:49.436Z"
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user