Improve product analytics UI
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m48s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m48s
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user