Improve product analytics UI
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m48s

This commit is contained in:
Cauê Faleiros
2026-07-01 10:57:33 -03:00
parent a26bc8813e
commit f5e2de9a35
6 changed files with 417 additions and 76 deletions

View File

@@ -7,24 +7,68 @@ import type { DateRange, ProductAnalyticsItem } from '../types';
import { exportToCSV, fetchProductAnalytics } from '../dataService';
import type { ProductSummary } from '../analytics/products';
type ProductHealth = {
label: string;
className: string;
};
const getDateOnlyTime = (value?: string | null) => {
if (!value) return 0;
const date = new Date(`${String(value).slice(0, 10)}T00:00:00`);
return Number.isNaN(date.getTime()) ? 0 : date.getTime();
};
const getProductHealth = (product: ProductSummary, dateRange: DateRange): ProductHealth => {
const endTime = new Date(dateRange.end.getFullYear(), dateRange.end.getMonth(), dateRange.end.getDate()).getTime();
const startTime = new Date(dateRange.start.getFullYear(), dateRange.start.getMonth(), dateRange.start.getDate()).getTime();
const rangeDays = Math.max(1, Math.round((endTime - startTime) / 86400000) + 1);
const lastSaleTime = getDateOnlyTime(product.lastSaleDate);
const firstSaleTime = getDateOnlyTime(product.firstSaleDate);
const daysSinceLastSale = lastSaleTime ? Math.max(0, Math.round((endTime - lastSaleTime) / 86400000)) : Infinity;
const daysSinceFirstSale = firstSaleTime ? Math.max(0, Math.round((endTime - firstSaleTime) / 86400000)) : Infinity;
if (product.totalSold > 0 && product.stock > 0 && product.stock <= Math.max(3, product.totalSold * 0.15)) {
return { label: 'Estoque baixo', className: 'border-amber-500/35 bg-amber-500/10 text-amber-700 dark:text-amber-300' };
}
if (!product.totalSold) {
return { label: 'Sem venda', className: 'border-zinc-500/25 bg-zinc-500/10 text-zinc-600 dark:text-zinc-400' };
}
if (daysSinceFirstSale <= Math.min(14, rangeDays)) {
return { label: 'Novo', className: 'border-cyan-500/35 bg-cyan-500/10 text-cyan-700 dark:text-cyan-300' };
}
if (daysSinceLastSale <= Math.max(1, Math.min(7, Math.ceil(rangeDays * 0.2)))) {
return { label: 'Quente', className: 'border-emerald-500/35 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300' };
}
if (daysSinceLastSale > Math.max(14, Math.ceil(rangeDays * 0.55))) {
return { label: 'Esfriando', className: 'border-orange-500/35 bg-orange-500/10 text-orange-700 dark:text-orange-300' };
}
return { label: 'Estável', className: 'border-sky-500/35 bg-sky-500/10 text-sky-700 dark:text-sky-300' };
};
const ProductTableSkeleton = () => (
<div className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl overflow-hidden shadow-sm" aria-label="Carregando produtos">
<div className="border-b border-zinc-100 p-4 dark:border-dark-border">
<div className="grid grid-cols-[120px_1.5fr_120px_100px_140px_110px] gap-6">
{[0, 1, 2, 3, 4, 5].map(item => (
<div className="grid grid-cols-[120px_1.5fr_120px_120px_100px_140px_110px] gap-6">
{[0, 1, 2, 3, 4, 5, 6].map(item => (
<div key={`products-head-skeleton-${item}`} className="skeleton h-3" />
))}
</div>
</div>
<div className="divide-y divide-zinc-100 dark:divide-dark-border">
{[0, 1, 2, 3, 4, 5, 6, 7].map(row => (
<div key={`products-row-skeleton-${row}`} className="grid grid-cols-[120px_1.5fr_120px_100px_140px_110px] gap-6 px-6 py-4">
<div key={`products-row-skeleton-${row}`} className="grid grid-cols-[120px_1.5fr_120px_120px_100px_140px_110px] gap-6 px-6 py-4">
<div className="skeleton h-4" />
<div>
<div className="skeleton h-4 w-4/5" />
<div className="skeleton mt-2 h-3 w-32" />
</div>
<div className="skeleton h-4" />
<div className="skeleton h-7 rounded-full" />
<div className="skeleton h-4" />
<div className="skeleton h-4" />
<div className="skeleton h-7 rounded-lg" />
@@ -79,7 +123,9 @@ const Products = () => {
totalSold: product.quantitySold,
revenue: product.revenue,
lastPrice: product.lastPrice,
stock: product.stock
stock: product.stock,
firstSaleDate: product.firstSaleDate,
lastSaleDate: product.lastSaleDate
}));
const filteredProducts = normalizedSearch
? products.filter(product =>
@@ -139,6 +185,8 @@ const Products = () => {
'Descrição': product.name,
'Preço Atual (R$)': product.lastPrice.toFixed(2).replace('.', ','),
'Total Vendido (un.)': product.totalSold,
'Status': getProductHealth(product, dateRange).label,
'Estoque': product.stock,
'Receita Gerada (R$)': product.revenue.toFixed(2).replace('.', ',')
}));
exportToCSV(exportData, `produtos_${new Date().toISOString().split('T')[0]}.csv`);
@@ -165,42 +213,52 @@ const Products = () => {
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">ID Produto</th>
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Descrição</th>
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Total Vendido</th>
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Status</th>
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Estoque</th>
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Receita Gerada</th>
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px] text-right">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-100 dark:divide-dark-border">
{paginatedData.map((product) => (
<tr key={product.id} className="hover:bg-zinc-50/80 dark:hover:bg-dark-input/50 transition-colors group">
<td className="px-6 py-2.5 font-mono text-[11px] text-zinc-400 dark:text-dark-muted">#{product.id}</td>
<td className="px-6 py-2.5">
<div className="font-semibold text-zinc-900 dark:text-dark-text">{product.name}</div>
<div className="text-[10px] text-zinc-400 dark:text-dark-muted font-medium">Preço Atual: {formatCurrency(product.lastPrice)}</div>
</td>
<td className="px-6 py-2.5">
<div className="flex items-center gap-2">
<Package className="w-3.5 h-3.5 text-zinc-400 dark:text-dark-muted" />
<span className="font-bold text-zinc-900 dark:text-dark-text">{product.totalSold} un.</span>
</div>
</td>
<td className="px-6 py-2.5">
<span className="font-bold text-zinc-900 dark:text-dark-text">
{product.stock} un.
</span>
</td>
<td className="px-6 py-2.5 text-brand-primary font-bold">{formatCurrency(product.revenue)}</td>
<td className="px-6 py-2.5 text-right">
<Link
to={`/products/${product.id}`}
className="inline-flex items-center text-xs font-bold text-brand-primary hover:opacity-80 transition-opacity bg-brand-primary/10 px-3 py-1.5 rounded-lg cursor-pointer"
>
<TrendingUp className="w-3.5 h-3.5 mr-1.5" />
Ver Gráfico
</Link>
</td>
</tr>
))}
{paginatedData.map((product) => {
const health = getProductHealth(product, dateRange);
return (
<tr key={product.id} className="hover:bg-zinc-50/80 dark:hover:bg-dark-input/50 transition-colors group">
<td className="px-6 py-2.5 font-mono text-[11px] text-zinc-400 dark:text-dark-muted">#{product.id}</td>
<td className="px-6 py-2.5">
<div className="font-semibold text-zinc-900 dark:text-dark-text">{product.name}</div>
<div className="text-[10px] text-zinc-400 dark:text-dark-muted font-medium">Preço Atual: {formatCurrency(product.lastPrice)}</div>
</td>
<td className="px-6 py-2.5">
<div className="flex items-center gap-2">
<Package className="w-3.5 h-3.5 text-zinc-400 dark:text-dark-muted" />
<span className="font-bold text-zinc-900 dark:text-dark-text">{product.totalSold} un.</span>
</div>
</td>
<td className="px-6 py-2.5">
<span className={`inline-flex rounded-full border px-2.5 py-1 text-[11px] font-bold ${health.className}`}>
{health.label}
</span>
</td>
<td className="px-6 py-2.5">
<span className="font-bold text-zinc-900 dark:text-dark-text">
{product.stock} un.
</span>
</td>
<td className="px-6 py-2.5 text-brand-primary font-bold">{formatCurrency(product.revenue)}</td>
<td className="px-6 py-2.5 text-right">
<Link
to={`/products/${product.id}`}
className="inline-flex items-center text-xs font-bold text-brand-primary hover:opacity-80 transition-opacity bg-brand-primary/10 px-3 py-1.5 rounded-lg cursor-pointer"
>
<TrendingUp className="w-3.5 h-3.5 mr-1.5" />
Ver Gráfico
</Link>
</td>
</tr>
);
})}
</tbody>
</table>
</div>