Add product group analysis view
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 41s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 41s
This commit is contained in:
454
src/pages/ProductGroupDetails.tsx
Normal file
454
src/pages/ProductGroupDetails.tsx
Normal file
@@ -0,0 +1,454 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useOutletContext, useParams } from 'react-router-dom';
|
||||
import { DollarSign, Package, Palette, Ruler, TrendingDown, TrendingUp, Warehouse } from 'lucide-react';
|
||||
import BackButton from '../components/BackButton';
|
||||
import DateRangePicker from '../components/DateRangePicker';
|
||||
import RefreshStatus from '../components/RefreshStatus';
|
||||
import { fetchProductAnalytics } from '../dataService';
|
||||
import { decodeProductGroupKey, normalizeProductText, parseProductName, sortProductSizes } from '../productParsing';
|
||||
import type { DateRange, ProductAnalyticsItem } from '../types';
|
||||
|
||||
type VariantRow = ProductAnalyticsItem & {
|
||||
color: string;
|
||||
size: string;
|
||||
dailySales: number;
|
||||
daysOfCover: number | null;
|
||||
};
|
||||
|
||||
type BreakdownRow = {
|
||||
label: string;
|
||||
quantitySold: number;
|
||||
revenue: number;
|
||||
stock: number;
|
||||
skuCount: number;
|
||||
};
|
||||
|
||||
const COLOR_SWATCHES: Array<{ pattern: string; color: string }> = [
|
||||
{ pattern: 'preto', color: '#171717' },
|
||||
{ pattern: 'branco', color: '#f8fafc' },
|
||||
{ pattern: 'bege', color: '#d7bf9a' },
|
||||
{ pattern: 'café', color: '#79553d' },
|
||||
{ pattern: 'cafe', color: '#79553d' },
|
||||
{ pattern: 'perola', color: '#e7dfcf' },
|
||||
{ pattern: 'pérola', color: '#e7dfcf' },
|
||||
{ pattern: 'marinho', color: '#172554' },
|
||||
{ pattern: 'bordo', color: '#6b1226' },
|
||||
{ pattern: 'bordô', color: '#6b1226' },
|
||||
{ pattern: 'verde', color: '#166534' },
|
||||
{ pattern: 'rosa', color: '#f0a6bf' },
|
||||
{ pattern: 'cinza', color: '#8f8f8f' },
|
||||
{ pattern: 'vermelho', color: '#b91c1c' },
|
||||
{ pattern: 'grafite', color: '#3f3f46' },
|
||||
{ pattern: 'azul', color: '#2563eb' }
|
||||
];
|
||||
|
||||
const getRangeDays = (range: DateRange) => {
|
||||
const start = new Date(range.start);
|
||||
const end = new Date(range.end);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
end.setHours(0, 0, 0, 0);
|
||||
return Math.max(1, Math.round((end.getTime() - start.getTime()) / 86_400_000) + 1);
|
||||
};
|
||||
|
||||
const formatNumber = (value: number, maximumFractionDigits = 0) => (
|
||||
new Intl.NumberFormat('pt-BR', { maximumFractionDigits }).format(value)
|
||||
);
|
||||
|
||||
const formatCurrency = (value: number) => (
|
||||
new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value)
|
||||
);
|
||||
|
||||
const formatDays = (value: number | null) => {
|
||||
if (value === null) return '-';
|
||||
if (value > 999) return '999+ dias';
|
||||
return `${formatNumber(value, value < 10 ? 1 : 0)} dias`;
|
||||
};
|
||||
|
||||
const getSwatchColor = (label: string) => {
|
||||
const normalizedLabel = label.normalize('NFD').replace(/\p{Diacritic}/gu, '').toLowerCase();
|
||||
return COLOR_SWATCHES.find(item => normalizedLabel.includes(item.pattern.normalize('NFD').replace(/\p{Diacritic}/gu, '')))?.color || '#64748b';
|
||||
};
|
||||
|
||||
const buildBreakdown = (rows: VariantRow[], field: 'color' | 'size') => {
|
||||
const totals = new Map<string, BreakdownRow>();
|
||||
|
||||
rows.forEach(row => {
|
||||
const label = row[field] || (field === 'color' ? 'Sem cor' : 'Sem tamanho');
|
||||
const current = totals.get(label) || { label, quantitySold: 0, revenue: 0, stock: 0, skuCount: 0 };
|
||||
totals.set(label, {
|
||||
label,
|
||||
quantitySold: current.quantitySold + row.quantitySold,
|
||||
revenue: current.revenue + row.revenue,
|
||||
stock: current.stock + row.stock,
|
||||
skuCount: current.skuCount + 1
|
||||
});
|
||||
});
|
||||
|
||||
const values = [...totals.values()];
|
||||
if (field === 'size') {
|
||||
return values.sort((a, b) => {
|
||||
const [sortedA, sortedB] = sortProductSizes([a.label, b.label]);
|
||||
if (sortedA === a.label && sortedB === b.label) return 0;
|
||||
return sortedA === a.label ? -1 : 1;
|
||||
});
|
||||
}
|
||||
|
||||
return values.sort((a, b) => b.quantitySold - a.quantitySold);
|
||||
};
|
||||
|
||||
const BreakdownPanel = ({
|
||||
title,
|
||||
subtitle,
|
||||
rows,
|
||||
type
|
||||
}: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
rows: BreakdownRow[];
|
||||
type: 'color' | 'size';
|
||||
}) => {
|
||||
const maxSold = Math.max(...rows.map(row => row.quantitySold), 0);
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
|
||||
<div className="mb-5 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-dark-text">{title}</h3>
|
||||
<p className="mt-1 text-sm font-medium text-dark-muted">{subtitle}</p>
|
||||
</div>
|
||||
{type === 'color' ? (
|
||||
<Palette className="h-5 w-5 text-brand-primary" />
|
||||
) : (
|
||||
<Ruler className="h-5 w-5 text-brand-primary" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<div className="flex h-32 items-center justify-center text-sm font-semibold text-dark-muted">
|
||||
Sem dados para este grupo.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{rows.map(row => {
|
||||
const width = maxSold ? Math.max(4, (row.quantitySold / maxSold) * 100) : 0;
|
||||
const swatchColor = type === 'color' ? getSwatchColor(row.label) : '#25c2ff';
|
||||
|
||||
return (
|
||||
<div key={row.label} className="grid grid-cols-[120px_1fr_92px] items-center gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
className="h-2.5 w-2.5 shrink-0 rounded-sm border border-white/20"
|
||||
style={{ backgroundColor: swatchColor }}
|
||||
/>
|
||||
<span className="truncate text-xs font-bold text-dark-text" title={row.label}>
|
||||
{type === 'size' && row.label !== 'Sem tamanho' ? `Tam. ${row.label}` : row.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2.5 overflow-hidden rounded-full bg-dark-border">
|
||||
<div
|
||||
className="h-full rounded-full"
|
||||
style={{ width: `${width}%`, backgroundColor: swatchColor, opacity: 0.86 }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-right text-xs">
|
||||
<div className="font-bold text-dark-text">{formatNumber(row.quantitySold)} un.</div>
|
||||
<div className="text-[10px] font-semibold text-dark-muted">{row.skuCount} SKUs</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ProductGroupDetailsSkeleton = () => (
|
||||
<div className="space-y-6" aria-label="Carregando grupo de produtos">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="skeleton h-4 w-20" />
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="skeleton h-16 w-16 rounded-2xl" />
|
||||
<div className="w-full max-w-xl">
|
||||
<div className="skeleton h-3 w-24" />
|
||||
<div className="skeleton mt-3 h-7 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||
{[0, 1, 2, 3].map(item => (
|
||||
<div key={`group-kpi-skeleton-${item}`} className="skeleton h-28 rounded-2xl" />
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||
<div className="skeleton h-80 rounded-2xl" />
|
||||
<div className="skeleton h-80 rounded-2xl" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const ProductGroupDetails = () => {
|
||||
const { groupKey } = useParams<{ groupKey: string }>();
|
||||
const { dateRange, setDateRange } = useOutletContext<{
|
||||
dateRange: DateRange,
|
||||
setDateRange: (range: DateRange) => void
|
||||
}>();
|
||||
const [products, setProducts] = useState<ProductAnalyticsItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const groupName = useMemo(() => {
|
||||
if (!groupKey) return '';
|
||||
|
||||
try {
|
||||
return decodeProductGroupKey(groupKey);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}, [groupKey]);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const loadProducts = async () => {
|
||||
setIsLoading(true);
|
||||
const data = await fetchProductAnalytics(dateRange);
|
||||
|
||||
if (isMounted) {
|
||||
setProducts(data);
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void loadProducts();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [dateRange]);
|
||||
|
||||
const groupRows = useMemo<VariantRow[]>(() => {
|
||||
const rangeDays = getRangeDays(dateRange);
|
||||
const normalizedGroupName = normalizeProductText(groupName).toLowerCase();
|
||||
|
||||
return products
|
||||
.map(product => {
|
||||
const metadata = parseProductName(product.name);
|
||||
const dailySales = product.quantitySold / rangeDays;
|
||||
|
||||
return {
|
||||
...product,
|
||||
color: metadata.color,
|
||||
size: metadata.size,
|
||||
baseName: metadata.baseName,
|
||||
dailySales,
|
||||
daysOfCover: dailySales > 0 ? product.stock / dailySales : null
|
||||
};
|
||||
})
|
||||
.filter(product => normalizeProductText(product.baseName).toLowerCase() === normalizedGroupName)
|
||||
.sort((a, b) => b.quantitySold - a.quantitySold);
|
||||
}, [dateRange, groupName, products]);
|
||||
|
||||
const totals = useMemo(() => {
|
||||
const totalSold = groupRows.reduce((total, row) => total + row.quantitySold, 0);
|
||||
const totalRevenue = groupRows.reduce((total, row) => total + row.revenue, 0);
|
||||
const totalStock = groupRows.reduce((total, row) => total + row.stock, 0);
|
||||
const dailySales = groupRows.reduce((total, row) => total + row.dailySales, 0);
|
||||
const daysOfCover = dailySales > 0 ? totalStock / dailySales : null;
|
||||
const colors = new Set(groupRows.map(row => row.color).filter(Boolean));
|
||||
const sizes = new Set(groupRows.map(row => row.size).filter(Boolean));
|
||||
|
||||
return {
|
||||
totalSold,
|
||||
totalRevenue,
|
||||
totalStock,
|
||||
dailySales,
|
||||
daysOfCover,
|
||||
colorCount: colors.size,
|
||||
sizeCount: sizes.size
|
||||
};
|
||||
}, [groupRows]);
|
||||
|
||||
const colorBreakdown = useMemo(() => buildBreakdown(groupRows, 'color'), [groupRows]);
|
||||
const sizeBreakdown = useMemo(() => buildBreakdown(groupRows, 'size'), [groupRows]);
|
||||
const isRefreshing = isLoading && products.length > 0;
|
||||
|
||||
if (isLoading && products.length === 0) {
|
||||
return <ProductGroupDetailsSkeleton />;
|
||||
}
|
||||
|
||||
if (!groupName || groupRows.length === 0) {
|
||||
return (
|
||||
<div className="py-12 text-center">
|
||||
<p className="font-medium text-zinc-500 dark:text-dark-muted">Grupo de produtos não encontrado.</p>
|
||||
<Link to="/products" className="mt-4 inline-block font-bold text-brand-primary hover:underline">
|
||||
Voltar para produtos
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex flex-col gap-4">
|
||||
<BackButton fallbackTo="/products" />
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl border border-zinc-200 bg-white text-brand-primary shadow-sm dark:border-dark-border dark:bg-dark-card">
|
||||
<Package className="h-8 w-8" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-bold uppercase tracking-widest text-zinc-400 dark:text-dark-muted">
|
||||
Grupo · {formatNumber(groupRows.length)} SKUs · {formatNumber(totals.colorCount)} cores · {formatNumber(totals.sizeCount)} tamanhos
|
||||
</p>
|
||||
<h1 className="truncate text-2xl font-bold text-zinc-900 dark:text-dark-text" title={groupName}>{groupName}</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DateRangePicker dateRange={dateRange} onChange={setDateRange} />
|
||||
</div>
|
||||
|
||||
<RefreshStatus isRefreshing={isRefreshing} />
|
||||
|
||||
<div className={isRefreshing ? 'refreshing-content space-y-6' : 'space-y-6'} aria-busy={isRefreshing}>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-bold uppercase tracking-widest text-dark-muted">Unidades vendidas</p>
|
||||
<p className="text-3xl font-bold text-dark-text">{formatNumber(totals.totalSold)}</p>
|
||||
</div>
|
||||
<Package className="h-6 w-6 text-brand-primary" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-bold uppercase tracking-widest text-dark-muted">Receita total</p>
|
||||
<p className="text-3xl font-bold text-dark-text">{formatCurrency(totals.totalRevenue)}</p>
|
||||
</div>
|
||||
<DollarSign className="h-6 w-6 text-emerald-300" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-bold uppercase tracking-widest text-dark-muted">Estoque</p>
|
||||
<p className="text-3xl font-bold text-dark-text">{formatNumber(totals.totalStock)}</p>
|
||||
</div>
|
||||
<Warehouse className="h-6 w-6 text-purple-300" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-bold uppercase tracking-widest text-dark-muted">Cobertura estimada</p>
|
||||
<p className="text-3xl font-bold text-dark-text">{formatDays(totals.daysOfCover)}</p>
|
||||
</div>
|
||||
<TrendingDown className="h-6 w-6 text-sky-300" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||
<BreakdownPanel
|
||||
title="Venda por cor"
|
||||
subtitle="Cores mais vendidas dentro deste grupo."
|
||||
rows={colorBreakdown}
|
||||
type="color"
|
||||
/>
|
||||
<BreakdownPanel
|
||||
title="Venda por tamanho"
|
||||
subtitle="Tamanhos mais vendidos dentro deste grupo."
|
||||
rows={sizeBreakdown}
|
||||
type="size"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm dark:border-dark-border dark:bg-dark-card">
|
||||
<div className="border-b border-zinc-100 px-6 py-4 dark:border-dark-border">
|
||||
<h3 className="text-base font-bold text-zinc-900 dark:text-dark-text">Variações do grupo</h3>
|
||||
<p className="mt-1 text-sm font-medium text-zinc-500 dark:text-dark-muted">SKUs, cores e tamanhos que formam este grupo.</p>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[1100px] table-fixed text-left text-sm">
|
||||
<colgroup>
|
||||
<col className="w-[120px]" />
|
||||
<col className="w-[360px]" />
|
||||
<col className="w-[120px]" />
|
||||
<col className="w-[100px]" />
|
||||
<col className="w-[120px]" />
|
||||
<col className="w-[120px]" />
|
||||
<col className="w-[140px]" />
|
||||
<col className="w-[130px]" />
|
||||
<col className="w-[130px]" />
|
||||
</colgroup>
|
||||
<thead className="border-b border-zinc-100 bg-zinc-50 text-zinc-500 dark:border-dark-border dark:bg-dark-header dark:text-dark-muted">
|
||||
<tr>
|
||||
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">ID Produto</th>
|
||||
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Descrição</th>
|
||||
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Cor</th>
|
||||
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Tamanho</th>
|
||||
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Vendido</th>
|
||||
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Estoque</th>
|
||||
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Média diária</th>
|
||||
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Receita</th>
|
||||
<th className="px-6 py-4 text-right text-[10px] font-bold uppercase tracking-wider">Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-100 dark:divide-dark-border">
|
||||
{groupRows.map(row => (
|
||||
<tr key={row.id} className="transition-colors hover:bg-zinc-50/80 dark:hover:bg-dark-input/50">
|
||||
<td className="px-6 py-2.5 font-mono text-[11px] text-zinc-400 dark:text-dark-muted">#{row.id}</td>
|
||||
<td className="max-w-0 px-6 py-2.5">
|
||||
<div className="truncate font-semibold text-zinc-900 dark:text-dark-text" title={row.name}>{row.name}</div>
|
||||
<div className="text-[10px] font-medium text-zinc-400 dark:text-dark-muted">Preço Atual: {formatCurrency(row.lastPrice)}</div>
|
||||
</td>
|
||||
<td className="px-6 py-2.5">
|
||||
<span className="inline-flex max-w-full items-center gap-2 rounded-full border border-sky-400/25 bg-sky-400/10 px-2.5 py-1 text-xs font-bold text-sky-300">
|
||||
<span
|
||||
className="h-2 w-2 shrink-0 rounded-sm border border-white/20"
|
||||
style={{ backgroundColor: getSwatchColor(row.color || 'Sem cor') }}
|
||||
/>
|
||||
<span className="truncate">{row.color || '-'}</span>
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-2.5">
|
||||
<span className="inline-flex rounded-full border border-emerald-400/25 bg-emerald-400/10 px-2.5 py-1 text-xs font-bold text-emerald-300">
|
||||
{row.size || '-'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-2.5 whitespace-nowrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<Package className="h-3.5 w-3.5 text-zinc-400 dark:text-dark-muted" />
|
||||
<span className="font-bold text-zinc-900 dark:text-dark-text">{formatNumber(row.quantitySold)} un.</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-2.5 whitespace-nowrap font-bold text-zinc-900 dark:text-dark-text">{formatNumber(row.stock)} un.</td>
|
||||
<td className="px-6 py-2.5 whitespace-nowrap text-zinc-500 dark:text-dark-muted">{formatNumber(row.dailySales, 2)} un./dia</td>
|
||||
<td className="px-6 py-2.5 whitespace-nowrap font-bold text-brand-primary">{formatCurrency(row.revenue)}</td>
|
||||
<td className="px-4 py-2.5 text-right">
|
||||
<Link
|
||||
to={`/products/${row.id}`}
|
||||
className="inline-flex items-center whitespace-nowrap rounded-lg bg-brand-primary/10 px-3 py-1.5 text-xs font-bold text-brand-primary transition-opacity hover:opacity-80"
|
||||
>
|
||||
<TrendingUp className="mr-1.5 h-3.5 w-3.5" />
|
||||
Ver SKU
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductGroupDetails;
|
||||
Reference in New Issue
Block a user