Stat cards fixes, mini component tweaks
This commit is contained in:
1106
docs/prod_registry.class.php
Normal file
1106
docs/prod_registry.class.php
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,6 @@ import {
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Package,
|
||||
Truck,
|
||||
@@ -16,7 +15,6 @@ import {
|
||||
XCircle,
|
||||
DollarSign,
|
||||
Activity,
|
||||
AlertCircle,
|
||||
FileText,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
@@ -66,7 +64,7 @@ const EVENT_TYPES = {
|
||||
gradient: "from-red-800 to-red-700",
|
||||
},
|
||||
[METRIC_IDS.PAYMENT_REFUNDED]: {
|
||||
label: "Payment Refunded",
|
||||
label: "Payment Refund",
|
||||
color: "bg-orange-200",
|
||||
textColor: "text-orange-50",
|
||||
iconColor: "text-orange-800",
|
||||
@@ -94,22 +92,22 @@ const EVENT_ICONS = {
|
||||
const LoadingState = () => (
|
||||
<div className="flex gap-3 px-4">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<Card key={i} className="w-[210px] h-[125px] shrink-0 bg-gradient-to-br from-gray-900 to-gray-800 backdrop-blur-sm">
|
||||
<Card key={i} className="w-[210px] h-[125px] shrink-0 bg-gradient-to-br from-gray-900 to-gray-800 backdrop-blur-md border-white/10">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 px-3 py-2 pb-0">
|
||||
<div className="flex items-baseline justify-between w-full pr-1">
|
||||
<Skeleton className="h-4 w-20 bg-gray-700" />
|
||||
<Skeleton className="h-3 w-14 bg-gray-700" />
|
||||
<Skeleton className="h-3 w-20 bg-white/20" />
|
||||
<Skeleton className="h-3 w-14 bg-white/20" />
|
||||
</div>
|
||||
<div className="relative p-2">
|
||||
<div className="absolute inset-0 rounded-full bg-gray-300" />
|
||||
<Skeleton className="h-4 w-4 bg-gray-700 relative rounded-full" />
|
||||
<div className="absolute inset-0 rounded-full bg-white/20" />
|
||||
<Skeleton className="h-4 w-4 bg-white/10 relative rounded-full" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-3 pt-1">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-7 w-36 bg-gray-700" />
|
||||
<Skeleton className="h-7 w-36 bg-white/20" />
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-4 w-28 bg-gray-700" />
|
||||
<Skeleton className="h-4 w-28 bg-white/10" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -120,12 +118,12 @@ const LoadingState = () => (
|
||||
|
||||
// Empty State Component
|
||||
const EmptyState = () => (
|
||||
<Card className="w-[210px] h-[125px] bg-gradient-to-br from-gray-900 to-gray-800 backdrop-blur-sm">
|
||||
<Card className="w-[210px] h-[125px] bg-gradient-to-br from-gray-900 to-gray-800 backdrop-blur-md border-white/10">
|
||||
<CardContent className="flex flex-col items-center justify-center h-full text-center p-4">
|
||||
<div className="bg-gray-800 rounded-full p-2 mb-2">
|
||||
<Activity className="h-4 w-4 text-gray-400" />
|
||||
<div className="bg-white/10 rounded-full p-2 mb-2">
|
||||
<Activity className="h-4 w-4 text-gray-300" />
|
||||
</div>
|
||||
<p className="text-sm text-gray-400 font-medium">
|
||||
<p className="text-xs font-medium text-gray-300 uppercase tracking-wide">
|
||||
No recent activity
|
||||
</p>
|
||||
</CardContent>
|
||||
@@ -141,14 +139,14 @@ const EventCard = ({ event }) => {
|
||||
|
||||
return (
|
||||
<EventDialog event={event}>
|
||||
<Card className={`w-[210px] border-none shrink-0 hover:brightness-110 cursor-pointer transition-colors h-[125px] bg-gradient-to-br ${eventType.gradient} backdrop-blur-sm`}>
|
||||
<Card className={`w-[230px] border-white/10 shrink-0 hover:brightness-110 cursor-pointer transition-all h-[125px] bg-gradient-to-br ${eventType.gradient} backdrop-blur-md`}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 px-3 py-2 pb-0">
|
||||
<div className="flex items-baseline justify-between w-full pr-1">
|
||||
<CardTitle className={`text-sm font-bold ${eventType.textColor}`}>
|
||||
<CardTitle className={`text-xs font-medium ${eventType.textColor} uppercase tracking-wide`}>
|
||||
{eventType.label}
|
||||
</CardTitle>
|
||||
{event.datetime && (
|
||||
<CardDescription className={`text-xs ${eventType.textColor} opacity-80`}>
|
||||
<CardDescription className={`text-xs ${eventType.textColor} opacity-70`}>
|
||||
{format(new Date(event.datetime), "h:mm a")}
|
||||
</CardDescription>
|
||||
)}
|
||||
|
||||
@@ -85,7 +85,7 @@ const MiniRealtimeAnalytics = () => {
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
if (loading && !basicData.byMinute?.length) {
|
||||
return (
|
||||
<div>
|
||||
<div className="grid grid-cols-2 gap-2 mt-1 mb-2">
|
||||
@@ -141,18 +141,18 @@ const MiniRealtimeAnalytics = () => {
|
||||
<DashboardStatCardMini
|
||||
title="Last 30 Minutes"
|
||||
value={basicData.last30MinUsers}
|
||||
description="Active users"
|
||||
subtitle="Active users"
|
||||
gradient="sky"
|
||||
icon={Users}
|
||||
iconBackground="bg-sky-300"
|
||||
iconBackground="bg-sky-400"
|
||||
/>
|
||||
<DashboardStatCardMini
|
||||
title="Last 5 Minutes"
|
||||
value={basicData.last5MinUsers}
|
||||
description="Active users"
|
||||
subtitle="Active users"
|
||||
gradient="sky"
|
||||
icon={Activity}
|
||||
iconBackground="bg-sky-300"
|
||||
iconBackground="bg-sky-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -140,31 +140,20 @@ const MiniSalesChart = ({ className = "" }) => {
|
||||
);
|
||||
}
|
||||
|
||||
// Helper to calculate trend direction
|
||||
const getRevenueTrend = () => {
|
||||
const current = summaryStats.periodProgress < 100
|
||||
? (projection?.projectedRevenue || summaryStats.totalRevenue)
|
||||
: summaryStats.totalRevenue;
|
||||
return current >= summaryStats.prevRevenue ? "up" : "down";
|
||||
};
|
||||
|
||||
// Helper to calculate trend values (positive = up, negative = down)
|
||||
const getRevenueTrendValue = () => {
|
||||
const current = summaryStats.periodProgress < 100
|
||||
? (projection?.projectedRevenue || summaryStats.totalRevenue)
|
||||
: summaryStats.totalRevenue;
|
||||
return `${Math.abs(Math.round((current - summaryStats.prevRevenue) / summaryStats.prevRevenue * 100))}%`;
|
||||
};
|
||||
|
||||
const getOrdersTrend = () => {
|
||||
const projected = Math.round(summaryStats.totalOrders * (100 / summaryStats.periodProgress));
|
||||
const current = summaryStats.periodProgress < 100 ? projected : summaryStats.totalOrders;
|
||||
return current >= summaryStats.prevOrders ? "up" : "down";
|
||||
if (!summaryStats.prevRevenue) return 0;
|
||||
return ((current - summaryStats.prevRevenue) / summaryStats.prevRevenue) * 100;
|
||||
};
|
||||
|
||||
const getOrdersTrendValue = () => {
|
||||
const projected = Math.round(summaryStats.totalOrders * (100 / summaryStats.periodProgress));
|
||||
const current = summaryStats.periodProgress < 100 ? projected : summaryStats.totalOrders;
|
||||
return `${Math.abs(Math.round((current - summaryStats.prevOrders) / summaryStats.prevOrders * 100))}%`;
|
||||
if (!summaryStats.prevOrders) return 0;
|
||||
return ((current - summaryStats.prevOrders) / summaryStats.prevOrders) * 100;
|
||||
};
|
||||
|
||||
if (loading && !data) {
|
||||
@@ -190,7 +179,7 @@ const MiniSalesChart = ({ className = "" }) => {
|
||||
<div className="space-y-2">
|
||||
{/* Stat Cards */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{loading ? (
|
||||
{loading && !data?.length ? (
|
||||
<>
|
||||
<DashboardStatCardMiniSkeleton gradient="slate" />
|
||||
<DashboardStatCardMiniSkeleton gradient="slate" />
|
||||
@@ -200,13 +189,10 @@ const MiniSalesChart = ({ className = "" }) => {
|
||||
<DashboardStatCardMini
|
||||
title="30 Days Revenue"
|
||||
value={formatCurrency(summaryStats.totalRevenue, false)}
|
||||
description={`Prev: ${formatCurrency(summaryStats.prevRevenue, false)}`}
|
||||
trend={{
|
||||
direction: getRevenueTrend(),
|
||||
value: getRevenueTrendValue(),
|
||||
}}
|
||||
subtitle={`Prev: ${formatCurrency(summaryStats.prevRevenue, false)}`}
|
||||
trend={{ value: getRevenueTrendValue() }}
|
||||
icon={PiggyBank}
|
||||
iconBackground="bg-emerald-300"
|
||||
iconBackground="bg-emerald-400"
|
||||
gradient="slate"
|
||||
className={!visibleMetrics.revenue ? 'opacity-50' : ''}
|
||||
onClick={() => toggleMetric('revenue')}
|
||||
@@ -214,13 +200,10 @@ const MiniSalesChart = ({ className = "" }) => {
|
||||
<DashboardStatCardMini
|
||||
title="30 Days Orders"
|
||||
value={summaryStats.totalOrders.toLocaleString()}
|
||||
description={`Prev: ${summaryStats.prevOrders.toLocaleString()}`}
|
||||
trend={{
|
||||
direction: getOrdersTrend(),
|
||||
value: getOrdersTrendValue(),
|
||||
}}
|
||||
subtitle={`Prev: ${summaryStats.prevOrders.toLocaleString()}`}
|
||||
trend={{ value: getOrdersTrendValue() }}
|
||||
icon={Truck}
|
||||
iconBackground="bg-blue-300"
|
||||
iconBackground="bg-blue-400"
|
||||
gradient="slate"
|
||||
className={!visibleMetrics.orders ? 'opacity-50' : ''}
|
||||
onClick={() => toggleMetric('orders')}
|
||||
@@ -233,7 +216,7 @@ const MiniSalesChart = ({ className = "" }) => {
|
||||
<Card className="bg-gradient-to-br from-slate-700 to-slate-600 backdrop-blur-sm">
|
||||
<CardContent className="p-4">
|
||||
<div className="h-[216px]">
|
||||
{loading ? (
|
||||
{loading && !data?.length ? (
|
||||
<ChartSkeleton height="sm" withCard={false} />
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
ShippingDetails,
|
||||
DetailDialog,
|
||||
formatCurrency,
|
||||
formatPercentage,
|
||||
} from "./StatCards";
|
||||
import {
|
||||
DashboardStatCardMini,
|
||||
@@ -112,8 +111,14 @@ const MiniStatCards = ({
|
||||
|
||||
const calculateOrderTrend = useCallback(() => {
|
||||
if (!stats?.prevPeriodOrders) return null;
|
||||
return calculateTrend(stats.orderCount, stats.prevPeriodOrders);
|
||||
}, [stats, calculateTrend]);
|
||||
|
||||
// If period is incomplete, use projected orders for fair comparison
|
||||
const currentOrders = stats.periodProgress < 100
|
||||
? (projection?.projectedOrders || Math.round(stats.orderCount / (stats.periodProgress / 100)))
|
||||
: stats.orderCount;
|
||||
|
||||
return calculateTrend(currentOrders, stats.prevPeriodOrders);
|
||||
}, [stats, projection, calculateTrend]);
|
||||
|
||||
const calculateAOVTrend = useCallback(() => {
|
||||
if (!stats?.prevPeriodAOV) return null;
|
||||
@@ -284,18 +289,18 @@ const MiniStatCards = ({
|
||||
<DashboardStatCardMini
|
||||
title="Today's Revenue"
|
||||
value={formatCurrency(stats?.revenue || 0)}
|
||||
description={
|
||||
subtitle={
|
||||
stats?.periodProgress < 100
|
||||
? `Proj: ${formatCurrency(projection?.projectedRevenue || stats.projectedRevenue)}`
|
||||
: undefined
|
||||
}
|
||||
trend={
|
||||
revenueTrend?.trend && !projectionLoading
|
||||
? { direction: revenueTrend.trend, value: formatPercentage(revenueTrend.value) }
|
||||
? { value: revenueTrend.trend === "up" ? revenueTrend.value : -revenueTrend.value }
|
||||
: undefined
|
||||
}
|
||||
icon={DollarSign}
|
||||
iconBackground="bg-emerald-300"
|
||||
iconBackground="bg-emerald-400"
|
||||
gradient="emerald"
|
||||
className="h-[150px]"
|
||||
onClick={() => setSelectedMetric("revenue")}
|
||||
@@ -304,14 +309,16 @@ const MiniStatCards = ({
|
||||
<DashboardStatCardMini
|
||||
title="Today's Orders"
|
||||
value={stats?.orderCount}
|
||||
description={`${stats?.itemCount} total items`}
|
||||
subtitle={`${stats?.itemCount} total items`}
|
||||
trend={
|
||||
orderTrend?.trend
|
||||
? { direction: orderTrend.trend, value: formatPercentage(orderTrend.value) }
|
||||
: undefined
|
||||
projectionLoading && stats?.periodProgress < 100
|
||||
? undefined
|
||||
: orderTrend?.trend
|
||||
? { value: orderTrend.trend === "up" ? orderTrend.value : -orderTrend.value }
|
||||
: undefined
|
||||
}
|
||||
icon={ShoppingCart}
|
||||
iconBackground="bg-blue-300"
|
||||
iconBackground="bg-blue-400"
|
||||
gradient="blue"
|
||||
className="h-[150px]"
|
||||
onClick={() => setSelectedMetric("orders")}
|
||||
@@ -321,14 +328,14 @@ const MiniStatCards = ({
|
||||
title="Today's AOV"
|
||||
value={stats?.averageOrderValue?.toFixed(2)}
|
||||
valuePrefix="$"
|
||||
description={`${stats?.averageItemsPerOrder?.toFixed(1)} items per order`}
|
||||
subtitle={`${stats?.averageItemsPerOrder?.toFixed(1)} items/order`}
|
||||
trend={
|
||||
aovTrend?.trend
|
||||
? { direction: aovTrend.trend, value: formatPercentage(aovTrend.value) }
|
||||
? { value: aovTrend.trend === "up" ? aovTrend.value : -aovTrend.value }
|
||||
: undefined
|
||||
}
|
||||
icon={CircleDollarSign}
|
||||
iconBackground="bg-violet-300"
|
||||
iconBackground="bg-violet-400"
|
||||
gradient="violet"
|
||||
className="h-[150px]"
|
||||
onClick={() => setSelectedMetric("average_order")}
|
||||
@@ -337,9 +344,9 @@ const MiniStatCards = ({
|
||||
<DashboardStatCardMini
|
||||
title="Shipped Today"
|
||||
value={stats?.shipping?.shippedCount || 0}
|
||||
description={`${stats?.shipping?.locations?.total || 0} locations`}
|
||||
subtitle={`${stats?.shipping?.locations?.total || 0} locations`}
|
||||
icon={Package}
|
||||
iconBackground="bg-orange-300"
|
||||
iconBackground="bg-orange-400"
|
||||
gradient="orange"
|
||||
className="h-[150px]"
|
||||
onClick={() => setSelectedMetric("shipping")}
|
||||
|
||||
@@ -237,8 +237,7 @@ const OrdersDetails = ({ data }) => {
|
||||
dataKey="orders"
|
||||
name="Orders"
|
||||
type="bar"
|
||||
color="
|
||||
"
|
||||
color="hsl(221.2 83.2% 53.3%)"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -376,7 +375,7 @@ const BrandsCategoriesDetails = ({ data }) => {
|
||||
</TableHeader>
|
||||
<TableBody className="max-h-[40vh] md:max-h-[60vh] overflow-auto">
|
||||
{brandsList.map((brand) => (
|
||||
<TableRow key={brand.name}>
|
||||
<TableRow key={brand.id || brand.name}>
|
||||
<TableCell className="font-medium">{brand.name}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{brand.count?.toLocaleString()}
|
||||
@@ -407,7 +406,7 @@ const BrandsCategoriesDetails = ({ data }) => {
|
||||
</TableHeader>
|
||||
<TableBody className="max-h-[40vh] md:max-h-[60vh] overflow-auto">
|
||||
{categoriesList.map((category) => (
|
||||
<TableRow key={category.name}>
|
||||
<TableRow key={category.id || category.name}>
|
||||
<TableCell className="font-medium">{category.name}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{category.count?.toLocaleString()}
|
||||
@@ -563,9 +562,9 @@ const OrderTypeDetails = ({ data, type }) => {
|
||||
);
|
||||
|
||||
const timeSeriesData = data.map((day) => ({
|
||||
timestamp: day.timestamp,
|
||||
count: day.count,
|
||||
value: day.value,
|
||||
timestamp: day.timestamp || day.date,
|
||||
count: day.count ?? day.orders, // Backend returns 'orders'
|
||||
value: day.value ?? day.revenue, // Backend returns 'revenue'
|
||||
percentage: day.percentage,
|
||||
}));
|
||||
|
||||
@@ -623,10 +622,11 @@ const PeakHourDetails = ({ data }) => {
|
||||
</div>
|
||||
);
|
||||
|
||||
// hourlyOrders is now an array of {hour, count} objects in chronological order (rolling 24hrs)
|
||||
const hourlyData =
|
||||
data[0]?.hourlyOrders?.map((count, hour) => ({
|
||||
timestamp: hour, // Use raw hour number for x-axis
|
||||
orders: count,
|
||||
data[0]?.hourlyOrders?.map((item) => ({
|
||||
timestamp: item.hour, // The actual hour (0-23)
|
||||
orders: item.count,
|
||||
})) || [];
|
||||
|
||||
return (
|
||||
@@ -996,13 +996,11 @@ const StatCards = ({
|
||||
const [lastUpdate, setLastUpdate] = useState(null);
|
||||
const [timeRange, setTimeRange] = useState(initialTimeRange);
|
||||
const [selectedMetric, setSelectedMetric] = useState(null);
|
||||
const [dateRange, setDateRange] = useState(null);
|
||||
const [detailDataLoading, setDetailDataLoading] = useState({});
|
||||
const [detailData, setDetailData] = useState({});
|
||||
const [isInitialLoad, setIsInitialLoad] = useState(true);
|
||||
const [projection, setProjection] = useState(null);
|
||||
const [projectionLoading, setProjectionLoading] = useState(false);
|
||||
const { setCacheData, getCacheData, clearCache } = useDataCache();
|
||||
const { setCacheData, getCacheData } = useDataCache();
|
||||
|
||||
// Function to determine if we should use last30days for trend charts
|
||||
const shouldUseLast30Days = useCallback(
|
||||
@@ -1218,8 +1216,14 @@ const StatCards = ({
|
||||
|
||||
const calculateOrderTrend = useCallback(() => {
|
||||
if (!stats?.prevPeriodOrders) return null;
|
||||
return calculateTrend(stats.orderCount, stats.prevPeriodOrders);
|
||||
}, [stats, calculateTrend]);
|
||||
|
||||
// If period is incomplete, use projected orders for fair comparison
|
||||
const currentOrders = stats.periodProgress < 100
|
||||
? (projection?.projectedOrders || Math.round(stats.orderCount / (stats.periodProgress / 100)))
|
||||
: stats.orderCount;
|
||||
|
||||
return calculateTrend(currentOrders, stats.prevPeriodOrders);
|
||||
}, [stats, projection, calculateTrend]);
|
||||
|
||||
const calculateAOVTrend = useCallback(() => {
|
||||
if (!stats?.prevPeriodAOV) return null;
|
||||
@@ -1242,7 +1246,6 @@ const StatCards = ({
|
||||
|
||||
if (!isMounted) return;
|
||||
|
||||
setDateRange(response.timeRange);
|
||||
setStats(response.stats);
|
||||
setLastUpdate(DateTime.now().setZone("America/New_York"));
|
||||
setError(null);
|
||||
@@ -1257,7 +1260,6 @@ const StatCards = ({
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
setLoading(false);
|
||||
setIsInitialLoad(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1321,69 +1323,30 @@ const StatCards = ({
|
||||
return () => clearInterval(interval);
|
||||
}, [timeRange]);
|
||||
|
||||
// Modified AsyncDetailView component
|
||||
const AsyncDetailView = memo(({ metric, type, orderCount }) => {
|
||||
const detailTimeRange = shouldUseLast30Days(metric)
|
||||
// Fetch detail data when a metric is selected (if not already cached)
|
||||
useEffect(() => {
|
||||
if (!selectedMetric) return;
|
||||
|
||||
// Skip metrics that use stats directly instead of fetched detail data
|
||||
if (["brands_categories", "shipping", "peak_hour"].includes(selectedMetric)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const detailTimeRange = shouldUseLast30Days(selectedMetric)
|
||||
? "last30days"
|
||||
: timeRange;
|
||||
const cachedData =
|
||||
detailData[metric] || getCacheData(detailTimeRange, metric);
|
||||
const isLoading = detailDataLoading[metric];
|
||||
const isOrderTypeMetric = [
|
||||
"pre_orders",
|
||||
"local_pickup",
|
||||
"on_hold",
|
||||
].includes(metric);
|
||||
const cachedData = detailData[selectedMetric] || getCacheData(detailTimeRange, selectedMetric);
|
||||
const isLoading = detailDataLoading[selectedMetric];
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const loadData = async () => {
|
||||
if (!cachedData && !isLoading) {
|
||||
// Pass type only for order type metrics
|
||||
const data = await fetchDetailData(
|
||||
metric,
|
||||
isOrderTypeMetric ? metric : undefined
|
||||
);
|
||||
if (!isMounted) return;
|
||||
// The state updates are handled in fetchDetailData
|
||||
}
|
||||
};
|
||||
|
||||
loadData();
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [metric, timeRange, isOrderTypeMetric]); // Depend on isOrderTypeMetric
|
||||
|
||||
if (isLoading || (!cachedData && !error)) {
|
||||
switch (metric) {
|
||||
case "revenue":
|
||||
case "orders":
|
||||
case "average_order":
|
||||
return <ChartSkeleton type="line" height="default" withCard={false} />;
|
||||
case "refunds":
|
||||
case "cancellations":
|
||||
case "order_range":
|
||||
case "pre_orders":
|
||||
case "local_pickup":
|
||||
case "on_hold":
|
||||
return <ChartSkeleton type="bar" height="default" withCard={false} />;
|
||||
case "brands_categories":
|
||||
case "shipping":
|
||||
return <TableSkeleton rows={8} columns={3} />;
|
||||
case "peak_hour":
|
||||
return <ChartSkeleton type="bar" height="default" withCard={false} />;
|
||||
default:
|
||||
return <div className="text-muted-foreground">Loading...</div>;
|
||||
}
|
||||
if (!cachedData && !isLoading) {
|
||||
const isOrderTypeMetric = ["pre_orders", "local_pickup", "on_hold"].includes(selectedMetric);
|
||||
fetchDetailData(selectedMetric, isOrderTypeMetric ? selectedMetric : undefined);
|
||||
}
|
||||
}, [selectedMetric, timeRange, shouldUseLast30Days, detailData, detailDataLoading, getCacheData, fetchDetailData]);
|
||||
|
||||
if (!cachedData && error) {
|
||||
return <DashboardErrorState error={`Failed to load stats: ${error}`} />;
|
||||
}
|
||||
|
||||
if (!cachedData) {
|
||||
// Modified getDetailComponent to use memoized components
|
||||
const getDetailComponent = useCallback(() => {
|
||||
if (!selectedMetric || !stats) {
|
||||
return (
|
||||
<DashboardEmptyState
|
||||
title="No data available"
|
||||
@@ -1393,57 +1356,22 @@ const StatCards = ({
|
||||
);
|
||||
}
|
||||
|
||||
switch (metric) {
|
||||
case "revenue":
|
||||
return <MemoizedRevenueDetails data={cachedData} />;
|
||||
case "orders":
|
||||
return <MemoizedOrdersDetails data={cachedData} />;
|
||||
case "average_order":
|
||||
return (
|
||||
<MemoizedAverageOrderDetails
|
||||
data={cachedData}
|
||||
orderCount={orderCount}
|
||||
/>
|
||||
);
|
||||
case "refunds":
|
||||
return <MemoizedRefundDetails data={cachedData} />;
|
||||
case "cancellations":
|
||||
return <MemoizedCancellationsDetails data={cachedData} />;
|
||||
case "order_range":
|
||||
return <MemoizedOrderRangeDetails data={cachedData} />;
|
||||
case "pre_orders":
|
||||
case "local_pickup":
|
||||
case "on_hold":
|
||||
return <MemoizedOrderTypeDetails data={cachedData} type={type} />;
|
||||
default:
|
||||
return (
|
||||
<div className="text-muted-foreground">Invalid metric selected.</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
AsyncDetailView.displayName = "AsyncDetailView";
|
||||
|
||||
// Modified getDetailComponent to use memoized components
|
||||
const getDetailComponent = useCallback(() => {
|
||||
if (!selectedMetric || !stats) {
|
||||
return (
|
||||
<div className="text-muted-foreground">
|
||||
No data available for the selected time range.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const data = detailData[selectedMetric];
|
||||
const isLoading = detailDataLoading[selectedMetric];
|
||||
const isOrderTypeMetric = [
|
||||
"pre_orders",
|
||||
"local_pickup",
|
||||
"on_hold",
|
||||
].includes(selectedMetric);
|
||||
|
||||
if (isLoading) {
|
||||
return <ChartSkeleton height="default" withCard={false} />;
|
||||
// Show metric-specific loading skeletons
|
||||
switch (selectedMetric) {
|
||||
case "brands_categories":
|
||||
case "shipping":
|
||||
return <TableSkeleton rows={8} columns={3} />;
|
||||
case "revenue":
|
||||
case "orders":
|
||||
case "average_order":
|
||||
return <ChartSkeleton type="line" height="default" withCard={false} />;
|
||||
default:
|
||||
return <ChartSkeleton type="bar" height="default" withCard={false} />;
|
||||
}
|
||||
}
|
||||
|
||||
switch (selectedMetric) {
|
||||
@@ -1659,7 +1587,7 @@ const StatCards = ({
|
||||
projectionLoading && stats?.periodProgress < 100
|
||||
? undefined
|
||||
: revenueTrend?.value
|
||||
? { value: revenueTrend.value, moreIsBetter: revenueTrend.trend === "up" }
|
||||
? { value: revenueTrend.trend === "up" ? revenueTrend.value : -revenueTrend.value, moreIsBetter: true }
|
||||
: undefined
|
||||
}
|
||||
icon={DollarSign}
|
||||
@@ -1672,7 +1600,13 @@ const StatCards = ({
|
||||
title="Orders"
|
||||
value={stats?.orderCount}
|
||||
subtitle={`${stats?.itemCount} total items`}
|
||||
trend={orderTrend?.value ? { value: orderTrend.value, moreIsBetter: orderTrend.trend === "up" } : undefined}
|
||||
trend={
|
||||
projectionLoading && stats?.periodProgress < 100
|
||||
? undefined
|
||||
: orderTrend?.value
|
||||
? { value: orderTrend.trend === "up" ? orderTrend.value : -orderTrend.value, moreIsBetter: true }
|
||||
: undefined
|
||||
}
|
||||
icon={ShoppingCart}
|
||||
iconColor="blue"
|
||||
onClick={() => setSelectedMetric("orders")}
|
||||
@@ -1684,7 +1618,7 @@ const StatCards = ({
|
||||
value={stats?.averageOrderValue?.toFixed(2)}
|
||||
valuePrefix="$"
|
||||
subtitle={`${stats?.averageItemsPerOrder?.toFixed(1)} items per order`}
|
||||
trend={aovTrend?.value ? { value: aovTrend.value, moreIsBetter: aovTrend.trend === "up" } : undefined}
|
||||
trend={aovTrend?.value ? { value: aovTrend.trend === "up" ? aovTrend.value : -aovTrend.value, moreIsBetter: true } : undefined}
|
||||
icon={CircleDollarSign}
|
||||
iconColor="purple"
|
||||
onClick={() => setSelectedMetric("average_order")}
|
||||
@@ -1714,7 +1648,9 @@ const StatCards = ({
|
||||
<DashboardStatCard
|
||||
title="Pre-Orders"
|
||||
value={
|
||||
((stats?.orderTypes?.preOrders?.count / stats?.orderCount) * 100)?.toFixed(1) || "0"
|
||||
stats?.orderCount > 0
|
||||
? ((stats?.orderTypes?.preOrders?.count / stats?.orderCount) * 100).toFixed(1)
|
||||
: "0"
|
||||
}
|
||||
valueSuffix="%"
|
||||
subtitle={`${stats?.orderTypes?.preOrders?.count || 0} orders`}
|
||||
@@ -1727,7 +1663,9 @@ const StatCards = ({
|
||||
<DashboardStatCard
|
||||
title="Local Pickup"
|
||||
value={
|
||||
((stats?.orderTypes?.localPickup?.count / stats?.orderCount) * 100)?.toFixed(1) || "0"
|
||||
stats?.orderCount > 0
|
||||
? ((stats?.orderTypes?.localPickup?.count / stats?.orderCount) * 100).toFixed(1)
|
||||
: "0"
|
||||
}
|
||||
valueSuffix="%"
|
||||
subtitle={`${stats?.orderTypes?.localPickup?.count || 0} orders`}
|
||||
@@ -1740,7 +1678,9 @@ const StatCards = ({
|
||||
<DashboardStatCard
|
||||
title="On Hold"
|
||||
value={
|
||||
((stats?.orderTypes?.heldItems?.count / stats?.orderCount) * 100)?.toFixed(1) || "0"
|
||||
stats?.orderCount > 0
|
||||
? ((stats?.orderTypes?.heldItems?.count / stats?.orderCount) * 100).toFixed(1)
|
||||
: "0"
|
||||
}
|
||||
valueSuffix="%"
|
||||
subtitle={`${stats?.orderTypes?.heldItems?.count || 0} orders`}
|
||||
|
||||
@@ -10,12 +10,18 @@
|
||||
* value="$12,345"
|
||||
* gradient="emerald"
|
||||
* icon={DollarSign}
|
||||
* trend={{ value: 12.5, label: "vs last month" }}
|
||||
* />
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { TrendingUp, TrendingDown, type LucideIcon } from "lucide-react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { ArrowUp, ArrowDown, Minus, Info, type LucideIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// =============================================================================
|
||||
@@ -35,6 +41,17 @@ export type GradientVariant =
|
||||
| "sky"
|
||||
| "custom";
|
||||
|
||||
export interface TrendProps {
|
||||
/** The percentage or absolute change value */
|
||||
value: number;
|
||||
/** Optional label to show after the trend (e.g., "vs last month") */
|
||||
label?: string;
|
||||
/** Whether a higher value is better (affects color). Defaults to true. */
|
||||
moreIsBetter?: boolean;
|
||||
/** Suffix for the trend value (defaults to "%"). Use "" for no suffix. */
|
||||
suffix?: string;
|
||||
}
|
||||
|
||||
export interface DashboardStatCardMiniProps {
|
||||
/** Card title/label */
|
||||
title: string;
|
||||
@@ -44,13 +61,10 @@ export interface DashboardStatCardMiniProps {
|
||||
valuePrefix?: string;
|
||||
/** Optional suffix for the value (e.g., "%") */
|
||||
valueSuffix?: string;
|
||||
/** Optional description text or element */
|
||||
description?: React.ReactNode;
|
||||
/** Trend direction and value */
|
||||
trend?: {
|
||||
direction: "up" | "down";
|
||||
value: string;
|
||||
};
|
||||
/** Optional subtitle or description (can be string or JSX) */
|
||||
subtitle?: React.ReactNode;
|
||||
/** Optional trend indicator */
|
||||
trend?: TrendProps;
|
||||
/** Optional icon component */
|
||||
icon?: LucideIcon;
|
||||
/** Icon background color class (e.g., "bg-emerald-500/20") */
|
||||
@@ -61,6 +75,12 @@ export interface DashboardStatCardMiniProps {
|
||||
className?: string;
|
||||
/** Click handler */
|
||||
onClick?: () => void;
|
||||
/** Loading state */
|
||||
loading?: boolean;
|
||||
/** Tooltip text shown via info icon next to title */
|
||||
tooltip?: string;
|
||||
/** Additional content to render below the main value */
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -81,6 +101,53 @@ const GRADIENT_PRESETS: Record<GradientVariant, string> = {
|
||||
custom: "",
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// HELPER COMPONENTS
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Get trend colors optimized for dark gradient backgrounds
|
||||
*/
|
||||
const getTrendColors = (value: number, moreIsBetter: boolean = true): string => {
|
||||
const isPositive = value > 0;
|
||||
const isGood = moreIsBetter ? isPositive : !isPositive;
|
||||
|
||||
if (value === 0) {
|
||||
return "text-gray-400";
|
||||
}
|
||||
return isGood ? "text-emerald-400" : "text-rose-400";
|
||||
};
|
||||
|
||||
interface TrendIndicatorProps {
|
||||
value: number;
|
||||
label?: string;
|
||||
moreIsBetter?: boolean;
|
||||
suffix?: string;
|
||||
}
|
||||
|
||||
const TrendIndicator: React.FC<TrendIndicatorProps> = ({
|
||||
value,
|
||||
label,
|
||||
moreIsBetter = true,
|
||||
suffix = "%",
|
||||
}) => {
|
||||
const colorClass = getTrendColors(value, moreIsBetter);
|
||||
const IconComponent = value > 0 ? ArrowUp : value < 0 ? ArrowDown : Minus;
|
||||
|
||||
// Format the value - round to integer for compact display (preserves sign for negatives)
|
||||
const formattedValue = Math.round(value);
|
||||
|
||||
return (
|
||||
<span className={cn("flex items-center gap-0.5 text-sm font-semibold", colorClass)}>
|
||||
<IconComponent className="h-4 w-4" />
|
||||
{value > 0 ? "+" : ""}
|
||||
{formattedValue}
|
||||
{suffix}
|
||||
{label && <span className="text-gray-300 font-normal ml-1">{label}</span>}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// MAIN COMPONENT
|
||||
// =============================================================================
|
||||
@@ -90,16 +157,41 @@ export const DashboardStatCardMini: React.FC<DashboardStatCardMiniProps> = ({
|
||||
value,
|
||||
valuePrefix,
|
||||
valueSuffix,
|
||||
description,
|
||||
subtitle,
|
||||
trend,
|
||||
icon: Icon,
|
||||
iconBackground,
|
||||
gradient = "slate",
|
||||
className,
|
||||
onClick,
|
||||
loading = false,
|
||||
tooltip,
|
||||
children,
|
||||
}) => {
|
||||
const gradientClass = gradient === "custom" ? "" : GRADIENT_PRESETS[gradient];
|
||||
|
||||
// Loading state
|
||||
if (loading) {
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
gradientClass,
|
||||
"backdrop-blur-md border-white/10",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 px-4 py-3">
|
||||
<div className="h-4 w-20 bg-white/20 animate-pulse rounded" />
|
||||
{Icon && <div className="h-9 w-9 bg-white/20 animate-pulse rounded-full" />}
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-1">
|
||||
<div className="h-9 w-28 bg-white/20 animate-pulse rounded mb-2" />
|
||||
{subtitle && <div className="h-4 w-24 bg-white/10 animate-pulse rounded" />}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
@@ -110,10 +202,28 @@ export const DashboardStatCardMini: React.FC<DashboardStatCardMiniProps> = ({
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2">
|
||||
<CardTitle className="text-sm font-bold text-gray-100">
|
||||
{title}
|
||||
</CardTitle>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 px-4 py-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<CardTitle className="text-xs font-medium text-gray-100 uppercase tracking-wide">
|
||||
{title}
|
||||
</CardTitle>
|
||||
{tooltip && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="text-gray-400 hover:text-gray-200 transition-colors"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Info className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs">
|
||||
<p className="text-sm">{tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
{Icon && (
|
||||
<div className="relative p-2">
|
||||
{iconBackground && (
|
||||
@@ -121,11 +231,11 @@ export const DashboardStatCardMini: React.FC<DashboardStatCardMiniProps> = ({
|
||||
className={cn("absolute inset-0 rounded-full", iconBackground)}
|
||||
/>
|
||||
)}
|
||||
<Icon className="h-5 w-5 text-white relative" />
|
||||
<Icon className="h-4 w-4 text-white relative" />
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0">
|
||||
<CardContent className="p-4 pt-1">
|
||||
<div className="text-3xl font-extrabold text-white">
|
||||
{valuePrefix}
|
||||
{typeof value === "number" ? value.toLocaleString() : value}
|
||||
@@ -133,32 +243,24 @@ export const DashboardStatCardMini: React.FC<DashboardStatCardMiniProps> = ({
|
||||
<span className="text-xl text-gray-300">{valueSuffix}</span>
|
||||
)}
|
||||
</div>
|
||||
{(description || trend) && (
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{trend && (
|
||||
<span
|
||||
className={cn(
|
||||
"flex items-center gap-1 text-sm font-semibold",
|
||||
trend.direction === "up"
|
||||
? "text-emerald-300"
|
||||
: "text-rose-300"
|
||||
)}
|
||||
>
|
||||
{trend.direction === "up" ? (
|
||||
<TrendingUp className="h-4 w-4" />
|
||||
) : (
|
||||
<TrendingDown className="h-4 w-4" />
|
||||
)}
|
||||
{trend.value}
|
||||
{(subtitle || trend) && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 mt-3">
|
||||
{subtitle && (
|
||||
<span className="text-sm font-semibold text-gray-200">
|
||||
{subtitle}
|
||||
</span>
|
||||
)}
|
||||
{description && (
|
||||
<span className="text-sm font-semibold text-gray-200">
|
||||
{description}
|
||||
</span>
|
||||
{trend && (
|
||||
<TrendIndicator
|
||||
value={trend.value}
|
||||
label={trend.label}
|
||||
moreIsBetter={trend.moreIsBetter}
|
||||
suffix={trend.suffix}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
@@ -170,12 +272,14 @@ export const DashboardStatCardMini: React.FC<DashboardStatCardMiniProps> = ({
|
||||
|
||||
export interface DashboardStatCardMiniSkeletonProps {
|
||||
gradient?: GradientVariant;
|
||||
hasIcon?: boolean;
|
||||
hasSubtitle?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const DashboardStatCardMiniSkeleton: React.FC<
|
||||
DashboardStatCardMiniSkeletonProps
|
||||
> = ({ gradient = "slate", className }) => {
|
||||
> = ({ gradient = "slate", hasIcon = true, hasSubtitle = true, className }) => {
|
||||
const gradientClass = gradient === "custom" ? "" : GRADIENT_PRESETS[gradient];
|
||||
|
||||
return (
|
||||
@@ -186,13 +290,13 @@ export const DashboardStatCardMiniSkeleton: React.FC<
|
||||
className
|
||||
)}
|
||||
>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 px-4 py-3">
|
||||
<div className="h-4 w-20 bg-white/20 animate-pulse rounded" />
|
||||
<div className="h-9 w-9 bg-white/20 animate-pulse rounded-full" />
|
||||
{hasIcon && <div className="h-9 w-9 bg-white/20 animate-pulse rounded-full" />}
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0">
|
||||
<CardContent className="p-4 pt-1">
|
||||
<div className="h-9 w-28 bg-white/20 animate-pulse rounded mb-2" />
|
||||
<div className="h-4 w-24 bg-white/10 animate-pulse rounded" />
|
||||
{hasSubtitle && <div className="h-4 w-24 bg-white/10 animate-pulse rounded" />}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
#!/bin/zsh
|
||||
|
||||
#Clear previous mount in case it’s still there
|
||||
umount '/Users/matt/Dev/inventory/inventory-server'
|
||||
|
||||
#Mount
|
||||
sshfs matt@dashboard.kent.pw:/var/www/html/inventory -p 22122 '/Users/matt/Dev/inventory/inventory-server/'
|
||||
Reference in New Issue
Block a user