From 66964a3218184a11877fbb59e514bda0b7003937 Mon Sep 17 00:00:00 2001 From: g Date: Tue, 13 Jan 2026 06:01:39 +0000 Subject: [PATCH] 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. --- components/admin/AdminAnalytics.tsx | 853 ++++++++++++---------------- components/admin/AdminStatCard.tsx | 166 ++++++ components/admin/TrendIndicator.tsx | 31 + public/git-info.json | 4 +- 4 files changed, 568 insertions(+), 486 deletions(-) create mode 100644 components/admin/AdminStatCard.tsx create mode 100644 components/admin/TrendIndicator.tsx diff --git a/components/admin/AdminAnalytics.tsx b/components/admin/AdminAnalytics.tsx index 89647b5..7965d14 100644 --- a/components/admin/AdminAnalytics.tsx +++ b/components/admin/AdminAnalytics.tsx @@ -47,6 +47,14 @@ import { } from "recharts"; import { formatGBP, formatNumber } from "@/lib/utils/format"; 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 { launchDate: string; @@ -417,33 +425,7 @@ export default function AdminAnalytics() { 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 ( -
= 0 ? "text-green-500" : "text-red-500"}`} - > - {percentChange >= 0 ? ( - - ) : ( - - )} - {Math.abs(percentChange).toFixed(1)}% -
- ); - }; // Format currency const formatCurrency = (value: number) => { @@ -707,234 +689,98 @@ export default function AdminAnalytics() {
{/* Orders Card */} - - -
- - Total Orders - -
- -
-
-
- -
- {formatNumber(analyticsData?.orders?.total)} -
-
- Today: {analyticsData?.orders?.totalToday || 0} -
- -
-
- - {loading || refreshing ? ( -
-
-
- ) : analyticsData?.orders?.dailyOrders && - analyticsData.orders.dailyOrders.length > 0 ? ( -
- - - - - - - - - - } cursor={{ stroke: 'rgba(255,255,255,0.1)', strokeWidth: 1 }} /> - - -
- ) : ( -
- No chart data -
- )} -
-
+ + Today: {analyticsData?.orders?.totalToday || 0} + + } + trend={{ + current: analyticsData?.orders?.totalToday || 0, + previous: (analyticsData?.orders?.total || 0) / 30, // Approx simple moving average + }} + loading={loading || refreshing} + chartData={transformChartData( + analyticsData?.orders?.dailyOrders || [], + "count" + )} + chartColor="#3b82f6" + chartGradientId="colorOrdersStat" + /> {/* Revenue Card */} - - -
- - Total Revenue - -
- -
-
-
- -
- {formatCurrency(analyticsData?.revenue?.total || 0)} -
-
- Today: {formatCurrency(analyticsData?.revenue?.today || 0)} -
- -
-
- - {loading || refreshing ? ( -
-
-
- ) : analyticsData?.revenue?.dailyRevenue && - analyticsData.revenue.dailyRevenue.length > 0 ? ( -
- - - - - - - - - - } cursor={{ stroke: 'rgba(255,255,255,0.1)', strokeWidth: 1 }} /> - - -
- ) : ( -
- No chart data -
- )} -
-
+ + Today: {formatCurrency(analyticsData?.revenue?.today || 0)} + + } + trend={{ + current: analyticsData?.revenue?.today || 0, + previous: (analyticsData?.revenue?.total || 0) / 30, + }} + loading={loading || refreshing} + chartData={transformChartData( + analyticsData?.revenue?.dailyRevenue || [], + "amount" + )} + chartColor="#10b981" + chartGradientId="colorRevenueStat" + tooltipPrefix="£" + /> {/* Vendors Card */} - - -
- Vendors -
- -
-
-
- -
- {analyticsData?.vendors?.total?.toLocaleString() || "0"} -
-
- Active: {analyticsData?.vendors?.active || 0} - Stores: {analyticsData?.vendors?.activeStores || 0} -
-
- New: {analyticsData?.vendors?.newToday || 0} -
- -
-
- - {loading || refreshing ? ( -
-
-
- ) : analyticsData?.vendors?.dailyGrowth && - analyticsData.vendors.dailyGrowth.length > 0 ? ( -
- - - - - - - - - - } cursor={{ stroke: 'rgba(255,255,255,0.1)', strokeWidth: 1 }} /> - - -
- ) : ( -
- No chart data -
- )} -
-
+ New: {analyticsData?.vendors?.newToday || 0}} + trend={{ + current: analyticsData?.vendors?.newToday || 0, + previous: (analyticsData?.vendors?.newThisWeek || 0) / 7, + }} + loading={loading || refreshing} + chartData={transformChartData( + analyticsData?.vendors?.dailyGrowth || [], + "count" + )} + chartColor="#8b5cf6" + chartGradientId="colorVendorsStat" + > +
+ + Active: {analyticsData?.vendors?.active || 0} + + + Stores: {analyticsData?.vendors?.activeStores || 0} + +
+
{/* Products Card */} - - -
- Products -
- -
-
-
- -
- {formatNumber(analyticsData?.products?.total)} -
-
- New This Week: {analyticsData?.products?.recent || 0} -
- {/* Visual spacer since no chart here */} -
- Inventory Overview -
-
-
+
@@ -960,251 +806,290 @@ export default function AdminAnalytics() { - - - Order Trends - - Daily order volume and revenue processed over the selected time period - - - - {loading || refreshing ? ( -
-
-
-

- Loading chart data... -

-
-
- ) : analyticsData?.orders?.dailyOrders && - analyticsData.orders.dailyOrders.length > 0 ? ( -
- - - - - - - `£${(value / 1000).toFixed(0)}k` - } - label={{ - value: "Revenue / AOV", - angle: 90, - position: "insideRight", - }} - /> - } /> - - - - - -
- ) : ( -
- No order data available for the selected time period -
- )} - - {/* Calculate totals for the selected period */} - {analyticsData?.orders?.dailyOrders && - analyticsData?.revenue?.dailyRevenue && ( -
-
-
- Total Revenue -
-
- {formatCurrency( - analyticsData.revenue.dailyRevenue.reduce( - (sum, day) => sum + (day.amount || 0), - 0, - ), + {loading || refreshing ? ( + + ) : ( + + + + Order Trends + + + Daily order volume and revenue processed over the selected time + period + + + + {analyticsData?.orders?.dailyOrders && + analyticsData.orders.dailyOrders.length > 0 ? ( +
+ + -
-
-
- Total Orders -
-
- {analyticsData.orders.dailyOrders - .reduce((sum, day) => sum + (day.count || 0), 0) - .toLocaleString()} -
-
+ margin={{ top: 5, right: 30, left: 20, bottom: 5 }} + > + + + + + + + + + + + + + + + `£${(value / 1000).toFixed(0)}k` + } + label={{ + value: "Revenue", + angle: 90, + position: "insideRight", + style: { fill: 'hsl(var(--muted-foreground))' } + }} + /> + } cursor={{ stroke: 'rgba(255,255,255,0.1)', strokeWidth: 1 }} /> + + + + +
+ ) : ( +
+ No order data available for the selected time period
)} - - + + {/* Calculate totals for the selected period */} + {analyticsData?.orders?.dailyOrders && + analyticsData?.revenue?.dailyRevenue && ( +
+
+
+ Total Revenue +
+
+ {formatCurrency( + analyticsData.revenue.dailyRevenue.reduce( + (sum, day) => sum + (day.amount || 0), + 0, + ), + )} +
+
+
+
+ Total Orders +
+
+ {analyticsData.orders.dailyOrders + .reduce((sum, day) => sum + (day.count || 0), 0) + .toLocaleString()} +
+
+
+ )} + + + )} - - - Vendor Growth - - New vendor registrations over time - - - - {loading || refreshing ? ( -
-
-
-

- Loading chart data... -

+ {loading || refreshing ? ( + + ) : ( + + + + Vendor Growth + + New vendor registrations over time + + + {analyticsData?.vendors?.dailyGrowth && + analyticsData.vendors.dailyGrowth.length > 0 ? ( +
+ + + + + + + + + + + + + } /> + +
-
- ) : analyticsData?.vendors?.dailyGrowth && - analyticsData.vendors.dailyGrowth.length > 0 ? ( -
- - - - - - } /> - - - -
- ) : ( -
- No vendor data available for the selected time period -
- )} - -
-
-
Total Vendors
-
- {formatNumber(analyticsData?.vendors?.total)} -
-
-
-
Active Vendors
-
- {formatNumber(analyticsData?.vendors?.active)} -
-
-
-
Active Stores
-
- {formatNumber(analyticsData?.vendors?.activeStores)} -
-
-
-
New This Week
-
- {formatNumber(analyticsData?.vendors?.newThisWeek)} -
-
-
- - {/* Top Vendors by Revenue */} - {analyticsData?.vendors?.topVendors && - analyticsData.vendors.topVendors.length > 0 && ( -
-

- Top Vendors by Revenue -

-
- {analyticsData.vendors.topVendors.map((vendor, index) => ( -
-
-
- {index + 1} -
-
-
- {vendor.vendorName} -
-
- {vendor.orderCount} orders -
-
-
-
-
- {formatCurrency(vendor.totalRevenue)} -
-
-
- ))} -
+ ) : ( +
+ No vendor data available for the selected time period
)} - - + +
+
+
Total Vendors
+
+ {formatNumber(analyticsData?.vendors?.total)} +
+
+
+
Active Vendors
+
+ {formatNumber(analyticsData?.vendors?.active)} +
+
+
+
Active Stores
+
+ {formatNumber(analyticsData?.vendors?.activeStores)} +
+
+
+
New This Week
+
+ {formatNumber(analyticsData?.vendors?.newThisWeek)} +
+
+
+ + {/* Top Vendors by Revenue */} + {analyticsData?.vendors?.topVendors && + analyticsData.vendors.topVendors.length > 0 && ( +
+

+ Top Vendors by Revenue +

+
+ {analyticsData.vendors.topVendors.map((vendor, index) => ( +
+
+
+ {index + 1} +
+
+
+ {vendor.vendorName} +
+
+ {vendor.orderCount} orders +
+
+
+
+
+ {formatCurrency(vendor.totalRevenue)} +
+
+
+ ))} +
+
+ )} + + + )} diff --git a/components/admin/AdminStatCard.tsx b/components/admin/AdminStatCard.tsx new file mode 100644 index 0000000..0e0b555 --- /dev/null +++ b/components/admin/AdminStatCard.tsx @@ -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 & { prefix?: string }) => { + if (active && payload && payload.length) { + const data = payload[0].payload; + return ( +
+

+ {data.formattedDate || label} +

+
+ + {prefix === "£" ? "Revenue" : "Count"} + + + {prefix}{prefix === "£" ? (data.value || 0).toFixed(2) : data.value} + +
+
+ ); + } + 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 ( + + +
+ + +
+
+ + +
+ + +
+ {!hideChart && } +
+
+ ); + } + + return ( + + +
+ + {title} + +
+ +
+
+
+ +
{value}
+ +
+ {subtext} + {trend && ( +
+ +
+ )} +
+ + {children &&
{children}
} + + {!hideChart && ( + chartData && chartData.length > 0 ? ( +
+ + + + + + + + + + } + cursor={{ stroke: 'rgba(255,255,255,0.1)', strokeWidth: 1 }} + /> + + +
+ ) : ( +
+ No chart data +
+ ) + )} + + {/* Fill space if chart is hidden but we want structure consistency */} + {hideChart &&
} + + + ); +} diff --git a/components/admin/TrendIndicator.tsx b/components/admin/TrendIndicator.tsx new file mode 100644 index 0000000..bf6faa6 --- /dev/null +++ b/components/admin/TrendIndicator.tsx @@ -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 ( +
= 0 ? "text-green-500" : "text-red-500"}`} + > + {percentChange >= 0 ? ( + + ) : ( + + )} + {Math.abs(percentChange).toFixed(1)}% +
+ ); +}; diff --git a/public/git-info.json b/public/git-info.json index 408c62e..38e1d6a 100644 --- a/public/git-info.json +++ b/public/git-info.json @@ -1,4 +1,4 @@ { - "commitHash": "4c15f43", - "buildTime": "2026-01-13T05:27:43.269Z" + "commitHash": "a07ca55", + "buildTime": "2026-01-13T05:50:49.436Z" } \ No newline at end of file