perf: load dashboard metrics from analytics API
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 50s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 50s
This commit is contained in:
@@ -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<Record<string, string>>((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;
|
||||
|
||||
@@ -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<OrderData[]>([]);
|
||||
const [stockData, setStockData] = useState<StockData[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isLoading, setIsLoading] = useState(needsRawData);
|
||||
const [refreshInterval, setRefreshInterval] = useState<number>(() => {
|
||||
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 */}
|
||||
<div className="flex-1 overflow-y-auto p-8 relative">
|
||||
{isLoading && (
|
||||
{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: isLoading, refreshInterval, setRefreshInterval, loadData }} />
|
||||
<Outlet context={{ dateRange, setDateRange, ordersData, stockData, isDataLoading: needsRawData && isLoading, refreshInterval, setRefreshInterval, loadData }} />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -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<Respo
|
||||
return response;
|
||||
};
|
||||
|
||||
const formatDateParam = (date: Date): string => {
|
||||
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<DashboardAnalytics | null> => {
|
||||
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<CampaignQueueSummary | null> => {
|
||||
try {
|
||||
const response = await authFetch('/campaigns');
|
||||
|
||||
@@ -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 (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-zinc-500 dark:text-dark-muted font-medium">Carregando cliente...</p>
|
||||
|
||||
@@ -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<DashboardAnalytics | null>(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 (
|
||||
<div className="space-y-6">
|
||||
@@ -69,10 +99,17 @@ const Dashboard = () => {
|
||||
onChange={setDateRange}
|
||||
refreshInterval={refreshInterval}
|
||||
setRefreshInterval={setRefreshInterval}
|
||||
onManualRefresh={() => loadData(true)}
|
||||
onManualRefresh={handleManualRefresh}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isMetricsLoading && (
|
||||
<div className="inline-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">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-brand-primary" />
|
||||
Atualizando indicadores
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm">
|
||||
<div className="flex justify-between items-start">
|
||||
|
||||
@@ -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 (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-zinc-500 dark:text-dark-muted font-medium">Carregando produto...</p>
|
||||
|
||||
16
src/types.ts
16
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 {
|
||||
|
||||
Reference in New Issue
Block a user