From b886b357d74d10e7b661f571b024d6f50e364d22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Thu, 28 May 2026 11:59:56 -0300 Subject: [PATCH] perf: load dashboard metrics from analytics API --- src/analytics/dashboard.ts | 27 +++++++++++++++++- src/components/Layout.tsx | 19 +++++++------ src/dataService.ts | 24 +++++++++++++++- src/pages/ClientDetails.tsx | 4 +-- src/pages/Dashboard.tsx | 53 ++++++++++++++++++++++++++++++------ src/pages/ProductDetails.tsx | 5 ++-- src/types.ts | 16 +++++++++++ 7 files changed, 126 insertions(+), 22 deletions(-) diff --git a/src/analytics/dashboard.ts b/src/analytics/dashboard.ts index 0f071f6..885db28 100644 --- a/src/analytics/dashboard.ts +++ b/src/analytics/dashboard.ts @@ -1,4 +1,4 @@ -import type { DateRange, OrderData } from '../types'; +import type { DashboardAnalytics, DateRange, OrderData } from '../types'; import { filterOrdersByDateRange, getBaseProductName, getOrderItemRevenue } from './orders'; const COLORS = [ @@ -34,6 +34,31 @@ export interface DashboardMetrics { revenueByProduct: ChartProductMetric[]; } +export const applyDashboardColors = (metrics: DashboardAnalytics): DashboardMetrics => { + const displayProducts = Array.from(new Set([ + ...metrics.salesByProduct.map(product => product.name), + ...metrics.revenueByProduct.map(product => product.name) + ])).sort(); + const productColors = displayProducts.reduce>((colors, name) => { + colors[name] = getProductColor(name); + return colors; + }, {}); + + return { + totalRevenue: metrics.totalRevenue, + totalOrders: metrics.totalOrders, + averageOrderValue: metrics.averageOrderValue, + salesByProduct: metrics.salesByProduct.map(product => ({ + ...product, + fill: productColors[product.name] + })), + revenueByProduct: metrics.revenueByProduct.map(product => ({ + ...product, + fill: productColors[product.name] + })) + }; +}; + export const buildDashboardMetrics = (ordersData: OrderData[], dateRange: DateRange): DashboardMetrics => { const filteredData = filterOrdersByDateRange(ordersData, dateRange); let revenue = 0; diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 0d82dfc..337e976 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -6,6 +6,7 @@ import { fetchData, fetchStock, logout } from '../dataService'; const Layout = () => { const location = useLocation(); + const needsRawData = location.pathname.startsWith('/products') || location.pathname.startsWith('/clients'); const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() => { return localStorage.getItem('graph_sidebar_collapsed') === 'true'; }); @@ -26,7 +27,7 @@ const Layout = () => { const [ordersData, setOrdersData] = useState([]); const [stockData, setStockData] = useState([]); - const [isLoading, setIsLoading] = useState(true); + const [isLoading, setIsLoading] = useState(needsRawData); const [refreshInterval, setRefreshInterval] = useState(() => { const saved = localStorage.getItem('nexstar_refresh_interval'); return saved ? Number(saved) : 0; @@ -44,20 +45,22 @@ const Layout = () => { }, []); useEffect(() => { - // The dashboard fetches its initial server state after mount without blocking route render. + if (!needsRawData) return; + + // Product and client pages still depend on raw orders until their API migration is complete. // eslint-disable-next-line react-hooks/set-state-in-effect - void loadData(false).finally(() => setIsLoading(false)); - }, [loadData]); + void loadData(true); + }, [loadData, needsRawData]); useEffect(() => { - if (refreshInterval === 0) return; + if (refreshInterval === 0 || !needsRawData) return; const intervalId = setInterval(() => { loadData(false); }, refreshInterval); return () => clearInterval(intervalId); - }, [loadData, refreshInterval]); + }, [loadData, needsRawData, refreshInterval]); useEffect(() => { localStorage.setItem('nexstar_refresh_interval', refreshInterval.toString()); @@ -149,13 +152,13 @@ const Layout = () => { {/* Content Area */}
- {isLoading && ( + {needsRawData && isLoading && (
Atualizando dados
)} - +
diff --git a/src/dataService.ts b/src/dataService.ts index b13f2e1..58d84b2 100644 --- a/src/dataService.ts +++ b/src/dataService.ts @@ -1,4 +1,4 @@ -import type { CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, OrderData, StockData } from './types'; +import type { CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, DashboardAnalytics, DateRange, OrderData, StockData } from './types'; const API_URL = import.meta.env.VITE_API_URL || '/api'; @@ -89,6 +89,28 @@ const authFetch = async (path: string, options: RequestInit = {}): Promise { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +}; + +export const fetchDashboardAnalytics = async (dateRange: DateRange): Promise => { + try { + const params = new URLSearchParams({ + start: formatDateParam(dateRange.start), + end: formatDateParam(dateRange.end) + }); + const response = await authFetch(`/analytics/dashboard?${params.toString()}`); + if (!response.ok) return null; + return await response.json(); + } catch (error) { + console.error('Fetch dashboard analytics failed', error); + return null; + } +}; + export const fetchCampaigns = async (): Promise => { try { const response = await authFetch('/campaigns'); diff --git a/src/pages/ClientDetails.tsx b/src/pages/ClientDetails.tsx index 2735934..02dfcdf 100644 --- a/src/pages/ClientDetails.tsx +++ b/src/pages/ClientDetails.tsx @@ -7,7 +7,7 @@ import { buildClientDetailsMetrics } from '../analytics/clients'; const ClientDetails = () => { const { name } = useParams<{ name: string }>(); const decodedName = name ? decodeURIComponent(name) : ''; - const { ordersData } = useOutletContext<{ ordersData: OrderData[] }>(); + const { ordersData, isDataLoading } = useOutletContext<{ ordersData: OrderData[], isDataLoading: boolean }>(); const { groupedOrders, totalSpent, totalItems, clientPhone } = useMemo(() => { return buildClientDetailsMetrics(ordersData, decodedName); @@ -17,7 +17,7 @@ const ClientDetails = () => { return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value); }; - if (!groupedOrders.length && ordersData.length === 0) { + if (!groupedOrders.length && isDataLoading) { return (

Carregando cliente...

diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index da7343d..529908e 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -1,10 +1,11 @@ -import { useMemo } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useOutletContext, useNavigate } from 'react-router-dom'; import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts'; -import { DollarSign, ShoppingCart, TrendingUp } from 'lucide-react'; +import { DollarSign, Loader2, ShoppingCart, TrendingUp } from 'lucide-react'; import DateRangePicker from '../components/DateRangePicker'; -import type { OrderData, DateRange } from '../types'; -import { buildDashboardMetrics } from '../analytics/dashboard'; +import type { DashboardAnalytics, OrderData, DateRange } from '../types'; +import { applyDashboardColors, buildDashboardMetrics } from '../analytics/dashboard'; +import { fetchDashboardAnalytics } from '../dataService'; const formatCurrency = (value: number) => { return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value); @@ -44,18 +45,47 @@ const CustomTooltip = ({ active, payload, label, isCurrency }: CustomTooltipProp const Dashboard = () => { const navigate = useNavigate(); - const { dateRange, setDateRange, ordersData, refreshInterval, setRefreshInterval, loadData } = useOutletContext<{ + const { dateRange, setDateRange, ordersData, refreshInterval, setRefreshInterval } = useOutletContext<{ dateRange: DateRange, setDateRange: (range: DateRange) => void, ordersData: OrderData[], refreshInterval: number, setRefreshInterval: (interval: number) => void, - loadData: (showLoading?: boolean) => void }>(); + const [serverMetrics, setServerMetrics] = useState(null); + const [isMetricsLoading, setIsMetricsLoading] = useState(true); + + const loadDashboardMetrics = useCallback(async (range: DateRange) => { + setIsMetricsLoading(true); + const metrics = await fetchDashboardAnalytics(range); + setServerMetrics(metrics); + setIsMetricsLoading(false); + }, []); + + useEffect(() => { + // Dashboard metrics are synchronized with the selected server-side date range. + // eslint-disable-next-line react-hooks/set-state-in-effect + void loadDashboardMetrics(dateRange); + }, [dateRange, loadDashboardMetrics]); + + useEffect(() => { + if (refreshInterval === 0) return; + + const intervalId = setInterval(() => { + void loadDashboardMetrics(dateRange); + }, refreshInterval); + + return () => clearInterval(intervalId); + }, [dateRange, loadDashboardMetrics, refreshInterval]); const { totalRevenue, totalOrders, averageOrderValue, salesByProduct, revenueByProduct } = useMemo(() => { + if (serverMetrics) return applyDashboardColors(serverMetrics); return buildDashboardMetrics(ordersData, dateRange); - }, [dateRange, ordersData]); + }, [dateRange, ordersData, serverMetrics]); + + const handleManualRefresh = () => { + void loadDashboardMetrics(dateRange); + }; return (
@@ -69,10 +99,17 @@ const Dashboard = () => { onChange={setDateRange} refreshInterval={refreshInterval} setRefreshInterval={setRefreshInterval} - onManualRefresh={() => loadData(true)} + onManualRefresh={handleManualRefresh} />
+ {isMetricsLoading && ( +
+ + Atualizando indicadores +
+ )} +
diff --git a/src/pages/ProductDetails.tsx b/src/pages/ProductDetails.tsx index 00fca55..8686d06 100644 --- a/src/pages/ProductDetails.tsx +++ b/src/pages/ProductDetails.tsx @@ -26,10 +26,11 @@ const CustomTooltip = ({ active, payload, label }: CustomTooltipProps) => { const ProductDetails = () => { const { id } = useParams<{ id: string }>(); - const { dateRange, setDateRange, ordersData } = useOutletContext<{ + const { dateRange, setDateRange, ordersData, isDataLoading } = useOutletContext<{ dateRange: DateRange, setDateRange: (range: DateRange) => void, ordersData: OrderData[], + isDataLoading: boolean, refreshInterval: number, setRefreshInterval: (interval: number) => void, loadData: (showLoading?: boolean) => void @@ -43,7 +44,7 @@ const ProductDetails = () => { return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value); }; - if (!productInfo && ordersData.length === 0) { + if (!productInfo && isDataLoading) { return (

Carregando produto...

diff --git a/src/types.ts b/src/types.ts index 51e0d8c..54ab872 100644 --- a/src/types.ts +++ b/src/types.ts @@ -24,6 +24,22 @@ export interface DateRange { end: Date; } +export interface DashboardAnalytics { + totalRevenue: number; + totalOrders: number; + averageOrderValue: number; + salesByProduct: Array<{ + name: string; + id: string; + value: number; + }>; + revenueByProduct: Array<{ + name: string; + id: string; + value: number; + }>; +} + export type CampaignStatus = 'pending' | 'processing' | 'sent' | 'failed' | 'skipped'; export interface CampaignQueueItem {