Compare commits

...

5 Commits

Author SHA1 Message Date
Cauê Faleiros
0d6ef40c8e fix: preserve etiqueta product variants
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 42s
2026-06-01 09:54:30 -03:00
Cauê Faleiros
fce7bbf975 fix: title case campaign product names 2026-06-01 09:47:35 -03:00
Cauê Faleiros
a1aa071e1d fix: normalize campaign product size suffixes
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m44s
2026-06-01 09:27:54 -03:00
Cauê Faleiros
b886b357d7 perf: load dashboard metrics from analytics API
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 50s
2026-05-28 11:59:56 -03:00
Cauê Faleiros
f4cf4366ee perf: avoid blocking page render on data refresh
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 41s
2026-05-28 11:46:11 -03:00
14 changed files with 265 additions and 44 deletions

View File

@@ -94,6 +94,30 @@ const initDB = async () => {
ALTER COLUMN sent_at TYPE TIMESTAMPTZ USING sent_at AT TIME ZONE 'America/Sao_Paulo'; ALTER COLUMN sent_at TYPE TIMESTAMPTZ USING sent_at AT TIME ZONE 'America/Sao_Paulo';
`).catch(() => {}); `).catch(() => {});
await pool.query(`
UPDATE stock_campaign_queue
SET base_product_name = TRIM(regexp_replace(
base_product_name,
'\\s+-\\s+(?:(?:PP|P|M|G|GG|XG|XGG|EG|EGG|EXG|U|UNICO|ÚNICO|\\d{2})(?:/(?:PP|P|M|G|GG|XG|XGG|EG|EGG|EXG|U|UNICO|ÚNICO|\\d{2}))*)$',
'',
'i'
))
WHERE status IN ('pending', 'failed', 'processing')
AND base_product_name ~* '\\s+-\\s+(?:(?:PP|P|M|G|GG|XG|XGG|EG|EGG|EXG|U|UNICO|ÚNICO|\\d{2})(?:/(?:PP|P|M|G|GG|XG|XGG|EG|EGG|EXG|U|UNICO|ÚNICO|\\d{2}))*)$';
`).catch(err => {
console.error('Notice: Could not normalize queued campaign product names:', err.message);
});
await pool.query(`
UPDATE stock_campaign_queue
SET base_product_name = nome
WHERE status IN ('pending', 'failed', 'processing')
AND nome ILIKE 'ETIQUETA%'
AND base_product_name != nome;
`).catch(err => {
console.error('Notice: Could not restore queued etiqueta product names:', err.message);
});
await pool.query(`CREATE INDEX IF NOT EXISTS idx_stock_campaign_queue_status ON stock_campaign_queue (status);`); await pool.query(`CREATE INDEX IF NOT EXISTS idx_stock_campaign_queue_status ON stock_campaign_queue (status);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_orders_cliente_fone ON orders (cliente_fone);`); await pool.query(`CREATE INDEX IF NOT EXISTS idx_orders_cliente_fone ON orders (cliente_fone);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_orders_produto_id ON orders (produto_id);`); await pool.query(`CREATE INDEX IF NOT EXISTS idx_orders_produto_id ON orders (produto_id);`);

View File

@@ -1,4 +1,16 @@
const getBaseProductName = (name) => String(name || 'Unknown').split(' TAMANHO')[0].trim(); const SIZE_SUFFIX_PATTERN = /\s+-\s+(?:(?:PP|P|M|G|GG|XG|XGG|EG|EGG|EXG|U|UNICO|ÚNICO|\d{2})(?:\/(?:PP|P|M|G|GG|XG|XGG|EG|EGG|EXG|U|UNICO|ÚNICO|\d{2}))*)$/i;
const getBaseProductName = (name) => {
const productName = String(name || 'Unknown').trim();
if (productName.toLocaleUpperCase('pt-BR').startsWith('ETIQUETA')) {
return productName;
}
return productName
.split(' TAMANHO')[0]
.replace(SIZE_SUFFIX_PATTERN, '')
.trim();
};
const normalizeStockPayload = (item) => { const normalizeStockPayload = (item) => {
const produtoId = item.idProduto || item.ID_Produto || ''; const produtoId = item.idProduto || item.ID_Produto || '';

View File

@@ -1,6 +1,12 @@
const { pool } = require('../db'); const { pool } = require('../db');
const PRODUCT_NAME_SQL = "NULLIF(TRIM(split_part(COALESCE(produto_descricao, 'Unknown'), ' TAMANHO', 1)), '')"; const SIZE_SUFFIX_SQL_PATTERN = '\\s+-\\s+(?:(?:PP|P|M|G|GG|XG|XGG|EG|EGG|EXG|U|UNICO|ÚNICO|\\d{2})(?:/(?:PP|P|M|G|GG|XG|XGG|EG|EGG|EXG|U|UNICO|ÚNICO|\\d{2}))*)$';
const PRODUCT_NAME_SQL = `
CASE
WHEN COALESCE(produto_descricao, 'Unknown') ILIKE 'ETIQUETA%' THEN COALESCE(produto_descricao, 'Unknown')
ELSE NULLIF(TRIM(regexp_replace(split_part(COALESCE(produto_descricao, 'Unknown'), ' TAMANHO', 1), '${SIZE_SUFFIX_SQL_PATTERN}', '', 'i')), '')
END
`;
const normalizeDateParam = (value) => { const normalizeDateParam = (value) => {
if (!value) return null; if (!value) return null;

View File

@@ -1,9 +1,19 @@
const formatProductNameForDisplay = (name) => {
return String(name || '')
.toLocaleLowerCase('pt-BR')
.replace(/(^|[\s/-])(\p{L})/gu, (_, separator, letter) => {
return `${separator}${letter.toLocaleUpperCase('pt-BR')}`;
});
};
const formatProductList = (productNames) => { const formatProductList = (productNames) => {
if (productNames.length <= 2) { const displayNames = productNames.map(formatProductNameForDisplay);
return productNames.join(' e ');
if (displayNames.length <= 2) {
return displayNames.join(' e ');
} }
return `${productNames.slice(0, -1).join(', ')} e ${productNames[productNames.length - 1]}`; return `${displayNames.slice(0, -1).join(', ')} e ${displayNames[displayNames.length - 1]}`;
}; };
const groupCampaignRowsByBaseProduct = (rows) => { const groupCampaignRowsByBaseProduct = (rows) => {
@@ -23,7 +33,7 @@ const mapCampaignProducts = (groups) => {
const sortedItems = [...items].sort((a, b) => String(a.nome).localeCompare(String(b.nome), 'pt-BR')); const sortedItems = [...items].sort((a, b) => String(a.nome).localeCompare(String(b.nome), 'pt-BR'));
return { return {
baseProduct: baseProductName, baseProduct: formatProductNameForDisplay(baseProductName),
total_delta: sortedItems.reduce((sum, item) => sum + Number(item.delta_estoque || 0), 0), total_delta: sortedItems.reduce((sum, item) => sum + Number(item.delta_estoque || 0), 0),
sizes: sortedItems.map(item => ({ sizes: sortedItems.map(item => ({
id: item.produto_id, id: item.produto_id,
@@ -85,6 +95,7 @@ const groupCampaignRows = (rows) => {
module.exports = { module.exports = {
buildWhatsappCampaignPayload, buildWhatsappCampaignPayload,
formatProductNameForDisplay,
formatProductList, formatProductList,
groupCampaignRows, groupCampaignRows,
groupCampaignRowsByBaseProduct, groupCampaignRowsByBaseProduct,

View File

@@ -3,6 +3,7 @@ const assert = require('node:assert/strict');
const { const {
buildWhatsappCampaignPayload, buildWhatsappCampaignPayload,
formatProductList, formatProductList,
formatProductNameForDisplay,
groupCampaignRows, groupCampaignRows,
groupCampaignRowsByBaseProduct, groupCampaignRowsByBaseProduct,
mapCampaignProducts mapCampaignProducts
@@ -26,14 +27,22 @@ const row = (overrides) => ({
test('formatProductList uses Portuguese list joining', () => { test('formatProductList uses Portuguese list joining', () => {
assert.equal(formatProductList([]), ''); assert.equal(formatProductList([]), '');
assert.equal(formatProductList(['BONÉ - PRETO']), 'BONÉ - PRETO'); assert.equal(formatProductList(['BONÉ - PRETO']), 'Boné - Preto');
assert.equal(formatProductList(['BONÉ - PRETO', 'BASE BRANCA']), 'BONÉ - PRETO e BASE BRANCA'); assert.equal(formatProductList(['BONÉ - PRETO', 'BASE BRANCA']), 'Boné - Preto e Base Branca');
assert.equal( assert.equal(
formatProductList(['BONÉ - PRETO', 'BASE BRANCA', 'BASE PRETA']), formatProductList(['BONÉ - PRETO', 'BASE BRANCA', 'BASE PRETA']),
'BONÉ - PRETO, BASE BRANCA e BASE PRETA' 'Boné - Preto, Base Branca e Base Preta'
); );
}); });
test('formatProductNameForDisplay converts campaign product names to title case', () => {
assert.equal(
formatProductNameForDisplay('BASE LISA MOLETOM CANGURU COR PRETO'),
'Base Lisa Moletom Canguru Cor Preto'
);
assert.equal(formatProductNameForDisplay('BONÉ - BRANCO'), 'Boné - Branco');
});
test('mapCampaignProducts accumulates split deltas by base product', () => { test('mapCampaignProducts accumulates split deltas by base product', () => {
const groups = groupCampaignRowsByBaseProduct([ const groups = groupCampaignRowsByBaseProduct([
row({ id: 1, delta_estoque: 10, produto_id: 'SKU-P', nome: 'Produto Split TAMANHO - P' }), row({ id: 1, delta_estoque: 10, produto_id: 'SKU-P', nome: 'Produto Split TAMANHO - P' }),
@@ -70,7 +79,7 @@ test('buildWhatsappCampaignPayload combines multiple ready products into one mes
])); ]));
const payload = buildWhatsappCampaignPayload(products, [{ nome: 'Cliente', fone: '5511999999999' }]); const payload = buildWhatsappCampaignPayload(products, [{ nome: 'Cliente', fone: '5511999999999' }]);
assert.equal(payload.productsText, 'BONÉ - PRETO e BASE LISA CAMISETA COR BRANCO'); assert.equal(payload.productsText, 'Boné - Preto e Base Lisa Camiseta Cor Branco');
assert.equal(payload.baseProduct, payload.productsText); assert.equal(payload.baseProduct, payload.productsText);
assert.equal(payload.total_delta, 200); assert.equal(payload.total_delta, 200);
assert.equal(payload.products.length, 2); assert.equal(payload.products.length, 2);

View File

@@ -0,0 +1,29 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const { getBaseProductName } = require('../mappers/stockMapper');
test('getBaseProductName strips TAMANHO suffixes', () => {
assert.equal(
getBaseProductName('BASE LISA CAMISETA COR BRANCO TAMANHO - P'),
'BASE LISA CAMISETA COR BRANCO'
);
});
test('getBaseProductName strips trailing size suffixes without removing colors', () => {
assert.equal(
getBaseProductName('BASE LISA MOLETOM CANGURU COR PRETO - M'),
'BASE LISA MOLETOM CANGURU COR PRETO'
);
assert.equal(
getBaseProductName('BASE LISA MOLETOM CANGURU COR PRETO - M/G/GG'),
'BASE LISA MOLETOM CANGURU COR PRETO'
);
assert.equal(getBaseProductName('BONÉ - BRANCO'), 'BONÉ - BRANCO');
});
test('getBaseProductName preserves etiqueta product variants', () => {
assert.equal(getBaseProductName('ETIQUETA 10X5 851UN'), 'ETIQUETA 10X5 851UN');
assert.equal(getBaseProductName('ETIQUETA BRANCA TAMANHO 08'), 'ETIQUETA BRANCA TAMANHO 08');
assert.equal(getBaseProductName('ETIQUETA BRANCA TAMANHO GG'), 'ETIQUETA BRANCA TAMANHO GG');
});

View File

@@ -1,4 +1,4 @@
import type { DateRange, OrderData } from '../types'; import type { DashboardAnalytics, DateRange, OrderData } from '../types';
import { filterOrdersByDateRange, getBaseProductName, getOrderItemRevenue } from './orders'; import { filterOrdersByDateRange, getBaseProductName, getOrderItemRevenue } from './orders';
const COLORS = [ const COLORS = [
@@ -34,6 +34,31 @@ export interface DashboardMetrics {
revenueByProduct: ChartProductMetric[]; 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 => { export const buildDashboardMetrics = (ordersData: OrderData[], dateRange: DateRange): DashboardMetrics => {
const filteredData = filterOrdersByDateRange(ordersData, dateRange); const filteredData = filterOrdersByDateRange(ordersData, dateRange);
let revenue = 0; let revenue = 0;

View File

@@ -1,8 +1,15 @@
import type { DateRange, OrderData } from '../types'; import type { DateRange, OrderData } from '../types';
import { parseOrderDate } from '../dataService'; import { parseOrderDate } from '../dataService';
const SIZE_SUFFIX_PATTERN = /\s+-\s+(?:(?:PP|P|M|G|GG|XG|XGG|EG|EGG|EXG|U|UNICO|ÚNICO|\d{2})(?:\/(?:PP|P|M|G|GG|XG|XGG|EG|EGG|EXG|U|UNICO|ÚNICO|\d{2}))*)$/i;
export const getBaseProductName = (description: string): string => { export const getBaseProductName = (description: string): string => {
return description.split(' TAMANHO')[0]; const productName = description.trim();
if (productName.toLocaleUpperCase('pt-BR').startsWith('ETIQUETA')) {
return productName;
}
return productName.split(' TAMANHO')[0].replace(SIZE_SUFFIX_PATTERN, '').trim();
}; };
export const getClientDisplayName = (order: OrderData): string => { export const getClientDisplayName = (order: OrderData): string => {

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'; import { useCallback, useState, useEffect } from 'react';
import { Outlet, Link, useLocation } from 'react-router-dom'; import { Outlet, Link, useLocation } from 'react-router-dom';
import { LayoutDashboard, Users, BarChart3, ChevronLeft, ChevronRight, Package, Loader2, LogOut, Megaphone } from 'lucide-react'; import { LayoutDashboard, Users, BarChart3, ChevronLeft, ChevronRight, Package, Loader2, LogOut, Megaphone } from 'lucide-react';
import type { DateRange, OrderData, StockData } from '../types'; import type { DateRange, OrderData, StockData } from '../types';
@@ -6,6 +6,7 @@ import { fetchData, fetchStock, logout } from '../dataService';
const Layout = () => { const Layout = () => {
const location = useLocation(); const location = useLocation();
const needsRawData = location.pathname.startsWith('/products') || location.pathname.startsWith('/clients');
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() => { const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() => {
return localStorage.getItem('graph_sidebar_collapsed') === 'true'; return localStorage.getItem('graph_sidebar_collapsed') === 'true';
}); });
@@ -26,35 +27,40 @@ const Layout = () => {
const [ordersData, setOrdersData] = useState<OrderData[]>([]); const [ordersData, setOrdersData] = useState<OrderData[]>([]);
const [stockData, setStockData] = useState<StockData[]>([]); const [stockData, setStockData] = useState<StockData[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(needsRawData);
const [refreshInterval, setRefreshInterval] = useState<number>(() => { const [refreshInterval, setRefreshInterval] = useState<number>(() => {
const saved = localStorage.getItem('nexstar_refresh_interval'); const saved = localStorage.getItem('nexstar_refresh_interval');
return saved ? Number(saved) : 0; return saved ? Number(saved) : 0;
}); });
const loadData = async (showLoading = false) => { const loadData = useCallback(async (showLoading = false) => {
if (showLoading) setIsLoading(true); if (showLoading) setIsLoading(true);
const [data, stock] = await Promise.all([fetchData(), fetchStock()]); try {
setOrdersData(data); const [data, stock] = await Promise.all([fetchData(), fetchStock()]);
setStockData(stock); setOrdersData(data);
if (showLoading) setIsLoading(false); setStockData(stock);
}; } finally {
if (showLoading) setIsLoading(false);
useEffect(() => { }
// The dashboard has to fetch its initial server state after mount.
// eslint-disable-next-line react-hooks/set-state-in-effect
void loadData(true);
}, []); }, []);
useEffect(() => { useEffect(() => {
if (refreshInterval === 0) return; 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(true);
}, [loadData, needsRawData]);
useEffect(() => {
if (refreshInterval === 0 || !needsRawData) return;
const intervalId = setInterval(() => { const intervalId = setInterval(() => {
loadData(false); loadData(false);
}, refreshInterval); }, refreshInterval);
return () => clearInterval(intervalId); return () => clearInterval(intervalId);
}, [refreshInterval]); }, [loadData, needsRawData, refreshInterval]);
useEffect(() => { useEffect(() => {
localStorage.setItem('nexstar_refresh_interval', refreshInterval.toString()); localStorage.setItem('nexstar_refresh_interval', refreshInterval.toString());
@@ -146,13 +152,13 @@ const Layout = () => {
{/* Content Area */} {/* Content Area */}
<div className="flex-1 overflow-y-auto p-8 relative"> <div className="flex-1 overflow-y-auto p-8 relative">
{isLoading ? ( {needsRawData && isLoading && (
<div className="flex items-center justify-center h-full"> <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="w-8 h-8 text-brand-primary animate-spin" /> <Loader2 className="h-4 w-4 animate-spin text-brand-primary" />
</div> Atualizando dados
) : ( </div>
<Outlet context={{ dateRange, setDateRange, ordersData, stockData, refreshInterval, setRefreshInterval, loadData }} />
)} )}
<Outlet context={{ dateRange, setDateRange, ordersData, stockData, isDataLoading: needsRawData && isLoading, refreshInterval, setRefreshInterval, loadData }} />
</div> </div>
</main> </main>
</div> </div>

View File

@@ -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'; 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; 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> => { export const fetchCampaigns = async (): Promise<CampaignQueueSummary | null> => {
try { try {
const response = await authFetch('/campaigns'); const response = await authFetch('/campaigns');

View File

@@ -7,7 +7,7 @@ import { buildClientDetailsMetrics } from '../analytics/clients';
const ClientDetails = () => { const ClientDetails = () => {
const { name } = useParams<{ name: string }>(); const { name } = useParams<{ name: string }>();
const decodedName = name ? decodeURIComponent(name) : ''; const decodedName = name ? decodeURIComponent(name) : '';
const { ordersData } = useOutletContext<{ ordersData: OrderData[] }>(); const { ordersData, isDataLoading } = useOutletContext<{ ordersData: OrderData[], isDataLoading: boolean }>();
const { groupedOrders, totalSpent, totalItems, clientPhone } = useMemo(() => { const { groupedOrders, totalSpent, totalItems, clientPhone } = useMemo(() => {
return buildClientDetailsMetrics(ordersData, decodedName); return buildClientDetailsMetrics(ordersData, decodedName);
@@ -17,6 +17,14 @@ const ClientDetails = () => {
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value); return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value);
}; };
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>
</div>
);
}
if (!groupedOrders.length) { if (!groupedOrders.length) {
return ( return (
<div className="text-center py-12"> <div className="text-center py-12">

View File

@@ -1,10 +1,11 @@
import { useMemo } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { useOutletContext, useNavigate } from 'react-router-dom'; import { useOutletContext, useNavigate } from 'react-router-dom';
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts'; 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 DateRangePicker from '../components/DateRangePicker';
import type { OrderData, DateRange } from '../types'; import type { DashboardAnalytics, OrderData, DateRange } from '../types';
import { buildDashboardMetrics } from '../analytics/dashboard'; import { applyDashboardColors, buildDashboardMetrics } from '../analytics/dashboard';
import { fetchDashboardAnalytics } from '../dataService';
const formatCurrency = (value: number) => { const formatCurrency = (value: number) => {
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value); 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 Dashboard = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { dateRange, setDateRange, ordersData, refreshInterval, setRefreshInterval, loadData } = useOutletContext<{ const { dateRange, setDateRange, ordersData, refreshInterval, setRefreshInterval } = useOutletContext<{
dateRange: DateRange, dateRange: DateRange,
setDateRange: (range: DateRange) => void, setDateRange: (range: DateRange) => void,
ordersData: OrderData[], ordersData: OrderData[],
refreshInterval: number, refreshInterval: number,
setRefreshInterval: (interval: number) => void, 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(() => { const { totalRevenue, totalOrders, averageOrderValue, salesByProduct, revenueByProduct } = useMemo(() => {
if (serverMetrics) return applyDashboardColors(serverMetrics);
return buildDashboardMetrics(ordersData, dateRange); return buildDashboardMetrics(ordersData, dateRange);
}, [dateRange, ordersData]); }, [dateRange, ordersData, serverMetrics]);
const handleManualRefresh = () => {
void loadDashboardMetrics(dateRange);
};
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -69,10 +99,17 @@ const Dashboard = () => {
onChange={setDateRange} onChange={setDateRange}
refreshInterval={refreshInterval} refreshInterval={refreshInterval}
setRefreshInterval={setRefreshInterval} setRefreshInterval={setRefreshInterval}
onManualRefresh={() => loadData(true)} onManualRefresh={handleManualRefresh}
/> />
</div> </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="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="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm">
<div className="flex justify-between items-start"> <div className="flex justify-between items-start">

View File

@@ -26,10 +26,11 @@ const CustomTooltip = ({ active, payload, label }: CustomTooltipProps) => {
const ProductDetails = () => { const ProductDetails = () => {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const { dateRange, setDateRange, ordersData } = useOutletContext<{ const { dateRange, setDateRange, ordersData, isDataLoading } = useOutletContext<{
dateRange: DateRange, dateRange: DateRange,
setDateRange: (range: DateRange) => void, setDateRange: (range: DateRange) => void,
ordersData: OrderData[], ordersData: OrderData[],
isDataLoading: boolean,
refreshInterval: number, refreshInterval: number,
setRefreshInterval: (interval: number) => void, setRefreshInterval: (interval: number) => void,
loadData: (showLoading?: boolean) => void loadData: (showLoading?: boolean) => void
@@ -43,6 +44,14 @@ const ProductDetails = () => {
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value); return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value);
}; };
if (!productInfo && isDataLoading) {
return (
<div className="text-center py-12">
<p className="text-zinc-500 dark:text-dark-muted font-medium">Carregando produto...</p>
</div>
);
}
if (!productInfo) { if (!productInfo) {
return ( return (
<div className="text-center py-12"> <div className="text-center py-12">

View File

@@ -24,6 +24,22 @@ export interface DateRange {
end: Date; 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 type CampaignStatus = 'pending' | 'processing' | 'sent' | 'failed' | 'skipped';
export interface CampaignQueueItem { export interface CampaignQueueItem {