Files
inventory/inventory/src/components/dashboard/MiniStatCards.jsx
2026-03-24 09:56:51 -04:00

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;