Optimize product detail analytics loading
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 43s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 43s
This commit is contained in:
@@ -1,13 +1,14 @@
|
||||
import { useCallback, useState, useEffect } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Outlet, Link, useLocation } from 'react-router-dom';
|
||||
import { LayoutDashboard, Users, BarChart3, ChevronLeft, ChevronRight, Package, Loader2, LogOut, Megaphone, Grid3X3, Shield } from 'lucide-react';
|
||||
import type { DateRange, OrderData, StockData } from '../types';
|
||||
import { fetchData, fetchStock, isSuperAdmin, logout } from '../dataService';
|
||||
import { LayoutDashboard, Users, BarChart3, ChevronLeft, ChevronRight, Package, LogOut, Megaphone, Grid3X3, Shield } from 'lucide-react';
|
||||
import type { DateRange, OrderData } from '../types';
|
||||
import { isSuperAdmin, logout } from '../dataService';
|
||||
import { rangeForLastDays } from '../dateRanges';
|
||||
|
||||
const emptyOrdersData: OrderData[] = [];
|
||||
|
||||
const Layout = () => {
|
||||
const location = useLocation();
|
||||
const needsRawData = location.pathname.startsWith('/products/');
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() => {
|
||||
return localStorage.getItem('graph_sidebar_collapsed') === 'true';
|
||||
});
|
||||
@@ -23,43 +24,11 @@ const Layout = () => {
|
||||
return rangeForLastDays(30);
|
||||
});
|
||||
|
||||
const [ordersData, setOrdersData] = useState<OrderData[]>([]);
|
||||
const [stockData, setStockData] = useState<StockData[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(needsRawData);
|
||||
const [refreshInterval, setRefreshInterval] = useState<number>(() => {
|
||||
const saved = localStorage.getItem('nexstar_refresh_interval');
|
||||
return saved ? Number(saved) : 0;
|
||||
});
|
||||
|
||||
const loadData = useCallback(async (showLoading = false) => {
|
||||
if (showLoading) setIsLoading(true);
|
||||
try {
|
||||
const [data, stock] = await Promise.all([fetchData(), fetchStock()]);
|
||||
setOrdersData(data);
|
||||
setStockData(stock);
|
||||
} finally {
|
||||
if (showLoading) setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!needsRawData) return;
|
||||
|
||||
// Product pages still depend on raw orders until their API migration is complete.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
void loadData(true);
|
||||
}, [loadData, needsRawData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (refreshInterval === 0 || !needsRawData) return;
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
loadData(false);
|
||||
}, refreshInterval);
|
||||
|
||||
return () => clearInterval(intervalId);
|
||||
}, [loadData, needsRawData, refreshInterval]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('nexstar_refresh_interval', refreshInterval.toString());
|
||||
}, [refreshInterval]);
|
||||
@@ -170,13 +139,7 @@ const Layout = () => {
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="flex-1 overflow-y-auto p-8 relative">
|
||||
{needsRawData && isLoading && (
|
||||
<div className="absolute right-8 top-8 z-10 flex items-center gap-2 rounded-xl border border-dark-border bg-dark-card px-3 py-2 text-sm font-semibold text-dark-muted shadow-sm">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-brand-primary" />
|
||||
Atualizando dados
|
||||
</div>
|
||||
)}
|
||||
<Outlet context={{ dateRange, setDateRange, ordersData, stockData, isDataLoading: needsRawData && isLoading, refreshInterval, setRefreshInterval, loadData }} />
|
||||
<Outlet context={{ dateRange, setDateRange, ordersData: emptyOrdersData, isDataLoading: false, refreshInterval, setRefreshInterval }} />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, ClientAnalyticsItem, ClientDetailsAnalytics, CreateUserResult, DashboardAnalytics, DateRange, ManagedUser, OrderData, ProductAnalyticsItem, RfmAnalytics, StockData } from './types';
|
||||
import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, ClientAnalyticsItem, ClientDetailsAnalytics, CreateUserResult, DashboardAnalytics, DateRange, ManagedUser, OrderData, ProductAnalyticsItem, ProductDetailsAnalytics, RfmAnalytics, StockData } from './types';
|
||||
import { formatDateParam } from './dateRanges';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || '/api';
|
||||
@@ -139,6 +139,21 @@ export const fetchProductAnalytics = async (dateRange: DateRange): Promise<Produ
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchProductDetailsAnalytics = async (productId: string, dateRange: DateRange): Promise<ProductDetailsAnalytics | null> => {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
start: formatDateParam(dateRange.start),
|
||||
end: formatDateParam(dateRange.end)
|
||||
});
|
||||
const response = await authFetch(`/analytics/products/${encodeURIComponent(productId)}/details?${params.toString()}`);
|
||||
if (!response.ok) return null;
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Fetch product details analytics failed', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchRfmAnalytics = async (dateRange: DateRange): Promise<RfmAnalytics | null> => {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, Link, useOutletContext } from 'react-router-dom';
|
||||
import { ArrowLeft, Package, DollarSign } from 'lucide-react';
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
import DateRangePicker from '../components/DateRangePicker';
|
||||
import type { OrderData, DateRange } from '../types';
|
||||
import { buildProductDetailsMetrics } from '../analytics/products';
|
||||
import type { DateRange, ProductDetailsAnalytics } from '../types';
|
||||
import { fetchProductDetailsAnalytics } from '../dataService';
|
||||
|
||||
type CustomTooltipProps = {
|
||||
active?: boolean;
|
||||
@@ -26,25 +26,46 @@ const CustomTooltip = ({ active, payload, label }: CustomTooltipProps) => {
|
||||
|
||||
const ProductDetails = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { dateRange, setDateRange, ordersData, isDataLoading } = useOutletContext<{
|
||||
const { dateRange, setDateRange } = useOutletContext<{
|
||||
dateRange: DateRange,
|
||||
setDateRange: (range: DateRange) => void,
|
||||
ordersData: OrderData[],
|
||||
isDataLoading: boolean,
|
||||
refreshInterval: number,
|
||||
setRefreshInterval: (interval: number) => void,
|
||||
loadData: (showLoading?: boolean) => void
|
||||
setDateRange: (range: DateRange) => void
|
||||
}>();
|
||||
const [details, setDetails] = useState<ProductDetailsAnalytics | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const { productInfo, chartData, totalSold, totalRevenue } = useMemo(() => {
|
||||
return buildProductDetailsMetrics(ordersData, id, dateRange);
|
||||
}, [id, dateRange, ordersData]);
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const loadProductDetails = async () => {
|
||||
if (!id) {
|
||||
if (isMounted) {
|
||||
setDetails(null);
|
||||
setIsLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
const productDetails = await fetchProductDetailsAnalytics(id, dateRange);
|
||||
|
||||
if (isMounted) {
|
||||
setDetails(productDetails);
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void loadProductDetails();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [dateRange, id]);
|
||||
|
||||
const formatCurrency = (value: number) => {
|
||||
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value);
|
||||
};
|
||||
|
||||
if (!productInfo && isDataLoading) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-zinc-500 dark:text-dark-muted font-medium">Carregando produto...</p>
|
||||
@@ -52,7 +73,7 @@ const ProductDetails = () => {
|
||||
);
|
||||
}
|
||||
|
||||
if (!productInfo) {
|
||||
if (!details?.productInfo) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-zinc-500 dark:text-dark-muted font-medium">Produto não encontrado.</p>
|
||||
@@ -61,6 +82,8 @@ const ProductDetails = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const { productInfo, chartData, totalSold, totalRevenue } = details;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
|
||||
18
src/types.ts
18
src/types.ts
@@ -81,6 +81,24 @@ export interface ProductAnalyticsItem {
|
||||
lastSaleDate: string | null;
|
||||
}
|
||||
|
||||
export interface ProductDetailsAnalytics {
|
||||
range: {
|
||||
start: string | null;
|
||||
end: string | null;
|
||||
};
|
||||
productInfo: {
|
||||
id: string;
|
||||
name: string;
|
||||
price: number;
|
||||
};
|
||||
chartData: Array<{
|
||||
date: string;
|
||||
value: number;
|
||||
}>;
|
||||
totalSold: number;
|
||||
totalRevenue: number;
|
||||
}
|
||||
|
||||
export interface ClientAnalyticsItem {
|
||||
customerKey: string;
|
||||
clientToken: string;
|
||||
|
||||
Reference in New Issue
Block a user