403 lines
14 KiB
JavaScript
403 lines
14 KiB
JavaScript
import React, { useState, useEffect, useCallback } from "react";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { acotService } from "@/services/dashboard/acotService";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import {
|
|
DollarSign,
|
|
ShoppingCart,
|
|
CircleDollarSign,
|
|
Users,
|
|
} from "lucide-react";
|
|
import { processBasicData } from "./RealtimeAnalytics";
|
|
|
|
// Import the detail view components and utilities from StatCards
|
|
import {
|
|
RevenueDetails,
|
|
OrdersDetails,
|
|
AverageOrderDetails,
|
|
ShippingDetails,
|
|
DetailDialog,
|
|
formatCurrency,
|
|
} from "./StatCards";
|
|
import {
|
|
DashboardStatCardMini,
|
|
DashboardStatCardMiniSkeleton,
|
|
ChartSkeleton,
|
|
TableSkeleton,
|
|
DashboardErrorState,
|
|
} from "@/components/dashboard/shared";
|
|
|
|
// Helper to map metric to colorVariant
|
|
const getColorVariant = (metric) => {
|
|
switch (metric) {
|
|
case 'revenue': return 'emerald';
|
|
case 'orders': return 'blue';
|
|
case 'average_order': return 'violet';
|
|
case 'shipping': return 'orange';
|
|
default: return 'default';
|
|
}
|
|
};
|
|
|
|
const MiniStatCards = ({
|
|
timeRange: initialTimeRange = "today",
|
|
startDate,
|
|
endDate,
|
|
title = "Quick Stats",
|
|
description = "",
|
|
compact = false,
|
|
}) => {
|
|
const [timeRange, setTimeRange] = useState(initialTimeRange);
|
|
const [selectedMetric, setSelectedMetric] = useState(null);
|
|
const [detailDataLoading, setDetailDataLoading] = useState({});
|
|
const [detailData, setDetailData] = useState({});
|
|
|
|
// Main stats query
|
|
const statsParams = timeRange === "custom" ? { startDate, endDate } : { timeRange };
|
|
const { data: stats, isLoading: loading, error: statsError } = useQuery({
|
|
queryKey: ["mini-stat-cards", timeRange, startDate, endDate],
|
|
queryFn: async () => {
|
|
const response = await acotService.getStats(statsParams);
|
|
return response.stats;
|
|
},
|
|
refetchInterval: timeRange === "today" ? 60000 : undefined,
|
|
});
|
|
|
|
// Projection query (depends on stats)
|
|
const { data: projection, isLoading: projectionLoading } = useQuery({
|
|
queryKey: ["mini-stat-projection", timeRange, startDate, endDate],
|
|
queryFn: () => acotService.getProjection(statsParams),
|
|
enabled: stats?.periodProgress != null && stats.periodProgress < 100,
|
|
refetchInterval: timeRange === "today" ? 60000 : undefined,
|
|
});
|
|
|
|
// Realtime users query
|
|
const { data: realtimeData = { last30MinUsers: 0, last5MinUsers: 0 }, isLoading: realtimeLoading } = useQuery({
|
|
queryKey: ["mini-realtime-users"],
|
|
queryFn: async () => {
|
|
const response = await fetch("/api/dashboard-analytics/realtime/basic", {
|
|
credentials: "include",
|
|
});
|
|
if (!response.ok) throw new Error("Failed to fetch realtime");
|
|
const result = await response.json();
|
|
return processBasicData(result.data);
|
|
},
|
|
refetchInterval: 30000,
|
|
});
|
|
|
|
const error = statsError?.message ?? null;
|
|
|
|
// Reuse the trend calculation functions
|
|
const calculateTrend = useCallback((current, previous) => {
|
|
if (!current || !previous) return null;
|
|
const trend = current >= previous ? "up" : "down";
|
|
const diff = Math.abs(current - previous);
|
|
const percentage = (diff / previous) * 100;
|
|
|
|
return {
|
|
trend,
|
|
value: percentage,
|
|
current,
|
|
previous,
|
|
};
|
|
}, []);
|
|
|
|
const calculateRevenueTrend = useCallback(() => {
|
|
if (!stats?.prevPeriodRevenue && stats?.prevPeriodRevenue !== 0) return null;
|
|
|
|
// If period is complete, use actual revenue
|
|
// If period is incomplete, use smart projection when available, fallback to simple projection
|
|
const currentRevenue = stats.periodProgress < 100
|
|
? (projection?.projectedRevenue || stats.projectedRevenue)
|
|
: stats.revenue;
|
|
const prevRevenue = stats.prevPeriodRevenue; // Previous period's total revenue
|
|
|
|
if (!currentRevenue || !prevRevenue) return null;
|
|
|
|
// Calculate absolute difference percentage
|
|
const trend = currentRevenue >= prevRevenue ? "up" : "down";
|
|
const diff = Math.abs(currentRevenue - prevRevenue);
|
|
const percentage = (diff / prevRevenue) * 100;
|
|
|
|
return {
|
|
trend,
|
|
value: percentage,
|
|
current: currentRevenue,
|
|
previous: prevRevenue,
|
|
};
|
|
}, [stats, projection]);
|
|
|
|
const calculateOrderTrend = useCallback(() => {
|
|
if (!stats?.prevPeriodOrders) return null;
|
|
|
|
// 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;
|
|
return calculateTrend(stats.averageOrderValue, stats.prevPeriodAOV);
|
|
}, [stats, calculateTrend]);
|
|
|
|
|
|
// Add function to fetch detail data
|
|
const fetchDetailData = useCallback(
|
|
async (metric) => {
|
|
if (detailData[metric]) return;
|
|
|
|
setDetailDataLoading((prev) => ({ ...prev, [metric]: true }));
|
|
try {
|
|
const response = await acotService.getStatsDetails({
|
|
timeRange: "last30days",
|
|
metric,
|
|
daily: true,
|
|
});
|
|
|
|
setDetailData((prev) => ({ ...prev, [metric]: response.stats }));
|
|
} catch (error) {
|
|
console.error(`Error fetching detail data for ${metric}:`, error);
|
|
} finally {
|
|
setDetailDataLoading((prev) => ({ ...prev, [metric]: false }));
|
|
}
|
|
},
|
|
[detailData]
|
|
);
|
|
|
|
// Add effect to load detail data when metric is selected
|
|
useEffect(() => {
|
|
if (selectedMetric) {
|
|
fetchDetailData(selectedMetric);
|
|
}
|
|
}, [selectedMetric, fetchDetailData]);
|
|
|
|
// Add preload effect with throttling
|
|
useEffect(() => {
|
|
// Preload detail data with throttling to avoid overwhelming the server
|
|
const preloadData = async () => {
|
|
const metrics = ["revenue", "orders", "average_order", "shipping"];
|
|
for (const metric of metrics) {
|
|
try {
|
|
await fetchDetailData(metric);
|
|
// Small delay between requests
|
|
await new Promise(resolve => setTimeout(resolve, 25));
|
|
} catch (error) {
|
|
console.error(`Error preloading ${metric}:`, error);
|
|
}
|
|
}
|
|
};
|
|
|
|
preloadData();
|
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="grid grid-cols-4 gap-2">
|
|
<DashboardStatCardMiniSkeleton gradient="emerald" className="h-[150px]" />
|
|
<DashboardStatCardMiniSkeleton gradient="blue" className="h-[150px]" />
|
|
<DashboardStatCardMiniSkeleton gradient="violet" className="h-[150px]" />
|
|
<DashboardStatCardMiniSkeleton gradient="sky" className="h-[150px]" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return <DashboardErrorState error={`Failed to load stats: ${error}`} />;
|
|
}
|
|
|
|
if (!stats) return null;
|
|
|
|
const revenueTrend = calculateRevenueTrend();
|
|
const orderTrend = calculateOrderTrend();
|
|
const aovTrend = calculateAOVTrend();
|
|
|
|
return (
|
|
<>
|
|
<div className="grid grid-cols-4 gap-2">
|
|
<DashboardStatCardMini
|
|
title="Today's Revenue"
|
|
value={formatCurrency(stats?.revenue || 0)}
|
|
subtitle={
|
|
stats?.periodProgress < 100
|
|
? `Proj: ${formatCurrency(projection?.projectedRevenue || stats.projectedRevenue)}`
|
|
: undefined
|
|
}
|
|
trend={
|
|
revenueTrend?.trend && !projectionLoading
|
|
? { value: revenueTrend.trend === "up" ? revenueTrend.value : -revenueTrend.value }
|
|
: undefined
|
|
}
|
|
icon={DollarSign}
|
|
iconBackground="bg-emerald-400"
|
|
gradient="emerald"
|
|
className="h-[150px]"
|
|
onClick={() => setSelectedMetric("revenue")}
|
|
/>
|
|
|
|
<DashboardStatCardMini
|
|
title="Today's Orders"
|
|
value={stats?.orderCount}
|
|
subtitle={`${stats?.itemCount} total items`}
|
|
trend={
|
|
projectionLoading && stats?.periodProgress < 100
|
|
? undefined
|
|
: orderTrend?.trend
|
|
? { value: orderTrend.trend === "up" ? orderTrend.value : -orderTrend.value }
|
|
: undefined
|
|
}
|
|
icon={ShoppingCart}
|
|
iconBackground="bg-blue-400"
|
|
gradient="blue"
|
|
className="h-[150px]"
|
|
onClick={() => setSelectedMetric("orders")}
|
|
/>
|
|
|
|
<DashboardStatCardMini
|
|
title="Today's AOV"
|
|
value={stats?.averageOrderValue?.toFixed(2)}
|
|
valuePrefix="$"
|
|
subtitle={`${stats?.averageItemsPerOrder?.toFixed(1)} items/order`}
|
|
trend={
|
|
aovTrend?.trend
|
|
? { value: aovTrend.trend === "up" ? aovTrend.value : -aovTrend.value }
|
|
: undefined
|
|
}
|
|
icon={CircleDollarSign}
|
|
iconBackground="bg-violet-400"
|
|
gradient="violet"
|
|
className="h-[150px]"
|
|
onClick={() => setSelectedMetric("average_order")}
|
|
/>
|
|
|
|
<DashboardStatCardMini
|
|
title="Live Users 5 Min"
|
|
value={realtimeLoading ? "..." : realtimeData.last5MinUsers}
|
|
subtitle={
|
|
realtimeLoading
|
|
? "Loading..."
|
|
: `${realtimeData.last30MinUsers} last 30 minutes`
|
|
}
|
|
icon={Users}
|
|
iconBackground="bg-sky-400"
|
|
gradient="sky"
|
|
className="h-[150px]"
|
|
loading={realtimeLoading}
|
|
/>
|
|
</div>
|
|
|
|
<Dialog
|
|
open={!!selectedMetric}
|
|
onOpenChange={() => setSelectedMetric(null)}
|
|
>
|
|
<DialogContent className={`w-[80vw] h-[80vh] max-w-none p-0 ${
|
|
selectedMetric === 'revenue' ? 'bg-emerald-50 dark:bg-emerald-950/30' :
|
|
selectedMetric === 'orders' ? 'bg-blue-50 dark:bg-blue-950/30' :
|
|
selectedMetric === 'average_order' ? 'bg-violet-50 dark:bg-violet-950/30' :
|
|
selectedMetric === 'shipping' ? 'bg-orange-50 dark:bg-orange-950/30' :
|
|
'bg-card'
|
|
} backdrop-blur-md border-none`}>
|
|
<div className="transform scale-[2] origin-top-left h-[40vh] w-[40vw]">
|
|
<div className="h-full w-full p-6">
|
|
<DialogHeader>
|
|
<DialogTitle className={`text-2xl font-bold ${
|
|
selectedMetric === 'revenue' ? 'text-emerald-900 dark:text-emerald-100' :
|
|
selectedMetric === 'orders' ? 'text-blue-900 dark:text-blue-100' :
|
|
selectedMetric === 'average_order' ? 'text-violet-900 dark:text-violet-100' :
|
|
selectedMetric === 'shipping' ? 'text-orange-900 dark:text-orange-100' :
|
|
'text-gray-900 dark:text-gray-100'
|
|
}`}>
|
|
{selectedMetric
|
|
? `${selectedMetric
|
|
.split("_")
|
|
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
.join(" ")} Details`
|
|
: ""}
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="mt-4 h-[calc(40vh-4rem)] overflow-auto [&::-webkit-scrollbar]:hidden [-ms-overflow-style:'none'] [scrollbar-width:'none']">
|
|
{detailDataLoading[selectedMetric] ? (
|
|
<div className="space-y-4 h-full">
|
|
{selectedMetric === "shipping" ? (
|
|
<TableSkeleton
|
|
rows={8}
|
|
columns={3}
|
|
colorVariant={getColorVariant(selectedMetric)}
|
|
/>
|
|
) : (
|
|
<>
|
|
<ChartSkeleton
|
|
type={selectedMetric === "orders" ? "bar" : "line"}
|
|
height="sm"
|
|
withCard={false}
|
|
colorVariant={getColorVariant(selectedMetric)}
|
|
/>
|
|
{selectedMetric === "orders" && (
|
|
<div className="mt-8">
|
|
<h3 className={`text-lg font-medium mb-4 ${
|
|
selectedMetric === 'revenue' ? 'text-emerald-900 dark:text-emerald-200' :
|
|
selectedMetric === 'orders' ? 'text-blue-900 dark:text-blue-200' :
|
|
selectedMetric === 'average_order' ? 'text-violet-900 dark:text-violet-200' :
|
|
selectedMetric === 'shipping' ? 'text-orange-900 dark:text-orange-200' :
|
|
'text-gray-900 dark:text-gray-200'
|
|
}`}>
|
|
Hourly Distribution
|
|
</h3>
|
|
<ChartSkeleton
|
|
type="bar"
|
|
height="sm"
|
|
withCard={false}
|
|
colorVariant={getColorVariant(selectedMetric)}
|
|
/>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="h-full">
|
|
{selectedMetric === "revenue" && (
|
|
<RevenueDetails
|
|
data={detailData.revenue || []}
|
|
colorScheme="emerald"
|
|
/>
|
|
)}
|
|
{selectedMetric === "orders" && (
|
|
<OrdersDetails
|
|
data={detailData.orders || []}
|
|
colorScheme="blue"
|
|
/>
|
|
)}
|
|
{selectedMetric === "average_order" && (
|
|
<AverageOrderDetails
|
|
data={detailData.average_order || []}
|
|
orderCount={stats.orderCount}
|
|
colorScheme="violet"
|
|
/>
|
|
)}
|
|
{selectedMetric === "shipping" && (
|
|
<ShippingDetails
|
|
data={[stats]}
|
|
timeRange={timeRange}
|
|
colorScheme="orange"
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default MiniStatCards;
|