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:
@@ -5,6 +5,7 @@ const {
|
|||||||
getClientDetailsAnalytics,
|
getClientDetailsAnalytics,
|
||||||
getDashboardAnalytics,
|
getDashboardAnalytics,
|
||||||
getProductAnalytics,
|
getProductAnalytics,
|
||||||
|
getProductDetailsAnalytics,
|
||||||
getRfmAnalytics
|
getRfmAnalytics
|
||||||
} = require('../services/analyticsService');
|
} = require('../services/analyticsService');
|
||||||
|
|
||||||
@@ -33,6 +34,21 @@ router.get('/analytics/products', verifyToken, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get('/analytics/products/:productId/details', verifyToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const details = await getProductDetailsAnalytics(req.params.productId, getRange(req.query));
|
||||||
|
if (!details) {
|
||||||
|
res.status(404).json({ error: 'Product not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(details);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching product details analytics:', error);
|
||||||
|
res.status(500).json({ error: 'Internal Server Error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.get('/analytics/clients', verifyToken, async (req, res) => {
|
router.get('/analytics/clients', verifyToken, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
res.json(await getClientAnalytics(getRange(req.query)));
|
res.json(await getClientAnalytics(getRange(req.query)));
|
||||||
|
|||||||
@@ -452,6 +452,98 @@ const getProductAnalytics = async (range = {}) => {
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getProductDetailsAnalytics = async (productId, range = {}) => {
|
||||||
|
const normalizedProductId = String(productId || '').trim();
|
||||||
|
if (!normalizedProductId) return null;
|
||||||
|
|
||||||
|
const normalizedStart = normalizeDateParam(range.start);
|
||||||
|
const normalizedEnd = normalizeDateParam(range.end);
|
||||||
|
const periodParams = [normalizedProductId];
|
||||||
|
const periodFilters = [
|
||||||
|
'produto_id = $1',
|
||||||
|
'data_pedido_date IS NOT NULL'
|
||||||
|
];
|
||||||
|
|
||||||
|
if (normalizedStart) {
|
||||||
|
periodParams.push(normalizedStart);
|
||||||
|
periodFilters.push(`data_pedido_date >= $${periodParams.length}::date`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedEnd) {
|
||||||
|
periodParams.push(normalizedEnd);
|
||||||
|
periodFilters.push(`data_pedido_date <= $${periodParams.length}::date`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [summaryResult, periodResult] = await Promise.all([
|
||||||
|
pool.query(`
|
||||||
|
WITH selected_product AS (
|
||||||
|
SELECT $1::text as id
|
||||||
|
),
|
||||||
|
stock_info AS (
|
||||||
|
SELECT
|
||||||
|
produto_id as id,
|
||||||
|
MAX(NULLIF(nome, '')) as name
|
||||||
|
FROM stock
|
||||||
|
WHERE produto_id = $1
|
||||||
|
GROUP BY produto_id
|
||||||
|
),
|
||||||
|
order_info AS (
|
||||||
|
SELECT
|
||||||
|
produto_id as id,
|
||||||
|
(ARRAY_AGG(COALESCE(NULLIF(produto_descricao, ''), 'Unknown') ORDER BY data_pedido_date DESC NULLS LAST, data_pedido DESC NULLS LAST))[1] as name,
|
||||||
|
(ARRAY_AGG(valor_unitario ORDER BY data_pedido_date DESC NULLS LAST, data_pedido DESC NULLS LAST))[1] as price
|
||||||
|
FROM orders
|
||||||
|
WHERE produto_id = $1
|
||||||
|
GROUP BY produto_id
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
selected_product.id,
|
||||||
|
COALESCE(stock_info.name, order_info.name, 'Unknown') as name,
|
||||||
|
COALESCE(order_info.price, 0) as price
|
||||||
|
FROM selected_product
|
||||||
|
LEFT JOIN stock_info ON stock_info.id = selected_product.id
|
||||||
|
LEFT JOIN order_info ON order_info.id = selected_product.id
|
||||||
|
WHERE stock_info.id IS NOT NULL OR order_info.id IS NOT NULL;
|
||||||
|
`, [normalizedProductId]),
|
||||||
|
pool.query(`
|
||||||
|
SELECT
|
||||||
|
data_pedido_date,
|
||||||
|
MAX(data_pedido) as date_label,
|
||||||
|
COALESCE(SUM(quantidade), 0) as quantity_sold,
|
||||||
|
COALESCE(SUM(quantidade * valor_unitario), 0) as revenue
|
||||||
|
FROM orders
|
||||||
|
WHERE ${periodFilters.join(' AND ')}
|
||||||
|
GROUP BY data_pedido_date
|
||||||
|
ORDER BY data_pedido_date ASC;
|
||||||
|
`, periodParams)
|
||||||
|
]);
|
||||||
|
|
||||||
|
const summary = summaryResult.rows[0];
|
||||||
|
if (!summary) return null;
|
||||||
|
|
||||||
|
const chartData = periodResult.rows.map(row => ({
|
||||||
|
date: row.date_label || getDateOnly(row.data_pedido_date) || '',
|
||||||
|
value: toNumber(row.quantity_sold)
|
||||||
|
}));
|
||||||
|
const totalSold = periodResult.rows.reduce((sum, row) => sum + toNumber(row.quantity_sold), 0);
|
||||||
|
const totalRevenue = periodResult.rows.reduce((sum, row) => sum + toNumber(row.revenue), 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
range: {
|
||||||
|
start: normalizedStart,
|
||||||
|
end: normalizedEnd
|
||||||
|
},
|
||||||
|
productInfo: {
|
||||||
|
id: summary.id,
|
||||||
|
name: summary.name,
|
||||||
|
price: toNumber(summary.price)
|
||||||
|
},
|
||||||
|
chartData,
|
||||||
|
totalSold,
|
||||||
|
totalRevenue
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const getClientAnalytics = async (range = {}) => {
|
const getClientAnalytics = async (range = {}) => {
|
||||||
const { params, whereClause } = buildDateFilter(range);
|
const { params, whereClause } = buildDateFilter(range);
|
||||||
const result = await pool.query(`
|
const result = await pool.query(`
|
||||||
@@ -837,6 +929,7 @@ module.exports = {
|
|||||||
getRfmSegment,
|
getRfmSegment,
|
||||||
getClientAnalytics,
|
getClientAnalytics,
|
||||||
getDashboardAnalytics,
|
getDashboardAnalytics,
|
||||||
|
getProductDetailsAnalytics,
|
||||||
getProductAnalytics,
|
getProductAnalytics,
|
||||||
normalizeDateParam,
|
normalizeDateParam,
|
||||||
scoreTertile
|
scoreTertile
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const {
|
|||||||
getClientDetailsAnalytics,
|
getClientDetailsAnalytics,
|
||||||
getPreviousDate,
|
getPreviousDate,
|
||||||
getProductAnalytics,
|
getProductAnalytics,
|
||||||
|
getProductDetailsAnalytics,
|
||||||
getRecencyScore,
|
getRecencyScore,
|
||||||
getRfmAnalytics,
|
getRfmAnalytics,
|
||||||
getRfmSegment,
|
getRfmSegment,
|
||||||
@@ -392,6 +393,97 @@ test('getProductAnalytics returns exact product rows with stock and latest price
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('getProductDetailsAnalytics returns product identity and period chart without raw order download', async () => {
|
||||||
|
const originalQuery = pool.query;
|
||||||
|
const calls = [];
|
||||||
|
|
||||||
|
pool.query = async (sql, params = []) => {
|
||||||
|
calls.push({ sql, params });
|
||||||
|
|
||||||
|
if (sql.includes('selected_product AS')) {
|
||||||
|
return {
|
||||||
|
rows: [{
|
||||||
|
id: '919483307',
|
||||||
|
name: 'BASE LISA CAMISETA COR PRETO TAMANHO - G',
|
||||||
|
price: 11.9
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
data_pedido_date: '2026-06-01',
|
||||||
|
date_label: '01-06-2026',
|
||||||
|
quantity_sold: 3,
|
||||||
|
revenue: 35.7
|
||||||
|
},
|
||||||
|
{
|
||||||
|
data_pedido_date: '2026-06-02',
|
||||||
|
date_label: '02-06-2026',
|
||||||
|
quantity_sold: 2,
|
||||||
|
revenue: 23.8
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const details = await getProductDetailsAnalytics('919483307', { start: '2026-06-01', end: '2026-06-22' });
|
||||||
|
|
||||||
|
assert.equal(calls.length, 2);
|
||||||
|
assert.match(calls[0].sql, /WITH selected_product AS/);
|
||||||
|
assert.match(calls[0].sql, /WHERE produto_id = \$1/);
|
||||||
|
assert.deepEqual(calls[0].params, ['919483307']);
|
||||||
|
assert.match(calls[1].sql, /produto_id = \$1/);
|
||||||
|
assert.match(calls[1].sql, /data_pedido_date >= \$2::date/);
|
||||||
|
assert.match(calls[1].sql, /data_pedido_date <= \$3::date/);
|
||||||
|
assert.deepEqual(calls[1].params, ['919483307', '2026-06-01', '2026-06-22']);
|
||||||
|
assert.deepEqual(details.productInfo, {
|
||||||
|
id: '919483307',
|
||||||
|
name: 'BASE LISA CAMISETA COR PRETO TAMANHO - G',
|
||||||
|
price: 11.9
|
||||||
|
});
|
||||||
|
assert.deepEqual(details.chartData, [
|
||||||
|
{ date: '01-06-2026', value: 3 },
|
||||||
|
{ date: '02-06-2026', value: 2 }
|
||||||
|
]);
|
||||||
|
assert.equal(details.totalSold, 5);
|
||||||
|
assert.equal(details.totalRevenue, 59.5);
|
||||||
|
} finally {
|
||||||
|
pool.query = originalQuery;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getProductDetailsAnalytics keeps known products visible with zero period sales', async () => {
|
||||||
|
const originalQuery = pool.query;
|
||||||
|
|
||||||
|
pool.query = async (sql) => {
|
||||||
|
if (sql.includes('selected_product AS')) {
|
||||||
|
return {
|
||||||
|
rows: [{
|
||||||
|
id: 'stock-only',
|
||||||
|
name: 'Produto sem venda no período',
|
||||||
|
price: 0
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { rows: [] };
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const details = await getProductDetailsAnalytics('stock-only', { start: '2026-06-01', end: '2026-06-22' });
|
||||||
|
|
||||||
|
assert.equal(details.productInfo.id, 'stock-only');
|
||||||
|
assert.equal(details.totalSold, 0);
|
||||||
|
assert.equal(details.totalRevenue, 0);
|
||||||
|
assert.deepEqual(details.chartData, []);
|
||||||
|
} finally {
|
||||||
|
pool.query = originalQuery;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('getClientAnalytics returns opaque client tokens', async () => {
|
test('getClientAnalytics returns opaque client tokens', async () => {
|
||||||
const originalQuery = pool.query;
|
const originalQuery = pool.query;
|
||||||
const calls = [];
|
const calls = [];
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { useCallback, useState, useEffect } from 'react';
|
import { 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, Grid3X3, Shield } from 'lucide-react';
|
import { LayoutDashboard, Users, BarChart3, ChevronLeft, ChevronRight, Package, LogOut, Megaphone, Grid3X3, Shield } from 'lucide-react';
|
||||||
import type { DateRange, OrderData, StockData } from '../types';
|
import type { DateRange, OrderData } from '../types';
|
||||||
import { fetchData, fetchStock, isSuperAdmin, logout } from '../dataService';
|
import { isSuperAdmin, logout } from '../dataService';
|
||||||
import { rangeForLastDays } from '../dateRanges';
|
import { rangeForLastDays } from '../dateRanges';
|
||||||
|
|
||||||
|
const emptyOrdersData: OrderData[] = [];
|
||||||
|
|
||||||
const Layout = () => {
|
const Layout = () => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const needsRawData = location.pathname.startsWith('/products/');
|
|
||||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() => {
|
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() => {
|
||||||
return localStorage.getItem('graph_sidebar_collapsed') === 'true';
|
return localStorage.getItem('graph_sidebar_collapsed') === 'true';
|
||||||
});
|
});
|
||||||
@@ -23,43 +24,11 @@ const Layout = () => {
|
|||||||
return rangeForLastDays(30);
|
return rangeForLastDays(30);
|
||||||
});
|
});
|
||||||
|
|
||||||
const [ordersData, setOrdersData] = useState<OrderData[]>([]);
|
|
||||||
const [stockData, setStockData] = useState<StockData[]>([]);
|
|
||||||
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 = 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(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem('nexstar_refresh_interval', refreshInterval.toString());
|
localStorage.setItem('nexstar_refresh_interval', refreshInterval.toString());
|
||||||
}, [refreshInterval]);
|
}, [refreshInterval]);
|
||||||
@@ -170,13 +139,7 @@ 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">
|
||||||
{needsRawData && isLoading && (
|
<Outlet context={{ dateRange, setDateRange, ordersData: emptyOrdersData, isDataLoading: false, refreshInterval, setRefreshInterval }} />
|
||||||
<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 }} />
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</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';
|
import { formatDateParam } from './dateRanges';
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL || '/api';
|
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> => {
|
export const fetchRfmAnalytics = async (dateRange: DateRange): Promise<RfmAnalytics | null> => {
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({
|
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 { useParams, Link, useOutletContext } from 'react-router-dom';
|
||||||
import { ArrowLeft, Package, DollarSign } from 'lucide-react';
|
import { ArrowLeft, Package, DollarSign } from 'lucide-react';
|
||||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||||
import DateRangePicker from '../components/DateRangePicker';
|
import DateRangePicker from '../components/DateRangePicker';
|
||||||
import type { OrderData, DateRange } from '../types';
|
import type { DateRange, ProductDetailsAnalytics } from '../types';
|
||||||
import { buildProductDetailsMetrics } from '../analytics/products';
|
import { fetchProductDetailsAnalytics } from '../dataService';
|
||||||
|
|
||||||
type CustomTooltipProps = {
|
type CustomTooltipProps = {
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
@@ -26,25 +26,46 @@ const CustomTooltip = ({ active, payload, label }: CustomTooltipProps) => {
|
|||||||
|
|
||||||
const ProductDetails = () => {
|
const ProductDetails = () => {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const { dateRange, setDateRange, ordersData, isDataLoading } = useOutletContext<{
|
const { dateRange, setDateRange } = useOutletContext<{
|
||||||
dateRange: DateRange,
|
dateRange: DateRange,
|
||||||
setDateRange: (range: DateRange) => void,
|
setDateRange: (range: DateRange) => void
|
||||||
ordersData: OrderData[],
|
|
||||||
isDataLoading: boolean,
|
|
||||||
refreshInterval: number,
|
|
||||||
setRefreshInterval: (interval: number) => void,
|
|
||||||
loadData: (showLoading?: boolean) => void
|
|
||||||
}>();
|
}>();
|
||||||
|
const [details, setDetails] = useState<ProductDetailsAnalytics | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
const { productInfo, chartData, totalSold, totalRevenue } = useMemo(() => {
|
useEffect(() => {
|
||||||
return buildProductDetailsMetrics(ordersData, id, dateRange);
|
let isMounted = true;
|
||||||
}, [id, dateRange, ordersData]);
|
|
||||||
|
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) => {
|
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);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!productInfo && isDataLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<p className="text-zinc-500 dark:text-dark-muted font-medium">Carregando produto...</p>
|
<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 (
|
return (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<p className="text-zinc-500 dark:text-dark-muted font-medium">Produto não encontrado.</p>
|
<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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
<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;
|
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 {
|
export interface ClientAnalyticsItem {
|
||||||
customerKey: string;
|
customerKey: string;
|
||||||
clientToken: string;
|
clientToken: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user