Files
graphs/src/pages/ProductGroupDetails.tsx
2026-07-14 14:23:28 -03:00

592 lines
27 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react';
import { Link, useOutletContext, useParams } from 'react-router-dom';
import { DollarSign, Eye, Package, Palette, Pencil, Ruler, TrendingDown, Warehouse } from 'lucide-react';
import BackButton from '../components/BackButton';
import DateRangePicker from '../components/DateRangePicker';
import PaginationControls from '../components/PaginationControls';
import ProductColorBadge, { ProductColorSwatch } from '../components/ProductColorBadge';
import RefreshStatus from '../components/RefreshStatus';
import { buildSkuEditPath } from '../catalogLinks';
import { buildOpenProductionByProductId } from '../analytics/cutting';
import { fetchProductAnalytics, fetchProductionOrders } from '../dataService';
import { decodeProductGroupKey, normalizeProductText, parseProductName } from '../productParsing';
import { formatColorLabel } from '../displayFormatters';
import { getProductColor } from '../productColors';
import type { DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types';
type VariantRow = ProductAnalyticsItem & {
color: string;
size: string;
dailySales: number;
projectedDemand: number;
openProductionQuantity: number;
availableQuantity: number;
suggestedReplenishment: number;
daysOfCover: number | null;
};
type BreakdownRow = {
label: string;
quantitySold: number;
revenue: number;
stock: number;
skuCount: number;
};
const BREAKDOWN_LIMIT = 12;
const REPLENISHMENT_TARGET_DAYS = 30;
const allProductionOrdersRange = {
start: new Date(2000, 0, 1),
end: new Date(2100, 11, 31)
};
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 getBarColor = (label: string) => {
const color = getProductColor(label);
return `color-mix(in srgb, ${color} 74%, var(--color-dark-text) 26%)`;
};
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
});
});
return [...totals.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);
const visibleRows = rows.slice(0, BREAKDOWN_LIMIT);
const hiddenCount = Math.max(0, rows.length - visibleRows.length);
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">
{visibleRows.map(row => {
const width = maxSold ? Math.max(4, (row.quantitySold / maxSold) * 100) : 0;
const barColor = type === 'color' ? getBarColor(row.label) : '#25c2ff';
const displayLabel = type === 'color' ? formatColorLabel(row.label) : row.label;
return (
<div key={row.label} className="grid grid-cols-[minmax(7.5rem,9rem)_1fr_6rem] items-center gap-3">
<div className="flex min-w-0 items-center gap-2">
{type === 'color' ? <ProductColorSwatch label={row.label} /> : <span className="h-2.5 w-2.5 shrink-0 rounded-sm bg-sky-400" />}
<span className="truncate text-xs font-bold text-dark-text" title={displayLabel}>
{type === 'size' && row.label !== 'Sem tamanho' ? `Tam. ${row.label}` : displayLabel}
</span>
</div>
<div className="h-3 overflow-hidden rounded-full border border-dark-border bg-dark-input">
<div
className="h-full rounded-full"
style={{ width: `${width}%`, backgroundColor: barColor }}
/>
</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>
);
})}
{hiddenCount > 0 && (
<div className="border-t border-dark-border pt-3 text-xs font-semibold text-dark-muted">
+{formatNumber(hiddenCount)} itens fora do top {BREAKDOWN_LIMIT}
</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 [productionOrders, setProductionOrders] = useState<ProductionOrderItem[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(20);
const groupName = useMemo(() => {
if (!groupKey) return '';
try {
return decodeProductGroupKey(groupKey);
} catch {
return '';
}
}, [groupKey]);
useEffect(() => {
let isMounted = true;
const loadProducts = async () => {
setIsLoading(true);
const [productData, productionOrderData] = await Promise.all([
fetchProductAnalytics(dateRange),
fetchProductionOrders(allProductionOrdersRange)
]);
if (isMounted) {
setProducts(productData);
setProductionOrders(productionOrderData.orders);
setIsLoading(false);
}
};
void loadProducts();
return () => {
isMounted = false;
};
}, [dateRange]);
const openProductionByProductId = useMemo(
() => buildOpenProductionByProductId(products, productionOrders),
[products, productionOrders]
);
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;
const projectedDemand = dailySales * REPLENISHMENT_TARGET_DAYS;
const openProductionQuantity = openProductionByProductId[product.id] || 0;
const availableQuantity = product.stock + openProductionQuantity;
const suggestedReplenishment = Math.max(0, Math.ceil(projectedDemand - availableQuantity));
return {
...product,
color: metadata.color,
size: metadata.size,
baseName: metadata.baseName,
dailySales,
projectedDemand,
openProductionQuantity,
availableQuantity,
suggestedReplenishment,
daysOfCover: dailySales > 0 ? availableQuantity / dailySales : null
};
})
.filter(product => normalizeProductText(product.baseName).toLowerCase() === normalizedGroupName)
.sort((a, b) => b.quantitySold - a.quantitySold);
}, [dateRange, groupName, openProductionByProductId, 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 openProductionQuantity = groupRows.reduce((total, row) => total + row.openProductionQuantity, 0);
const availableQuantity = groupRows.reduce((total, row) => total + row.availableQuantity, 0);
const dailySales = groupRows.reduce((total, row) => total + row.dailySales, 0);
const projectedDemand = groupRows.reduce((total, row) => total + row.projectedDemand, 0);
const suggestedReplenishment = Math.max(0, Math.ceil(projectedDemand - availableQuantity));
const daysOfCover = dailySales > 0 ? availableQuantity / 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,
openProductionQuantity,
availableQuantity,
dailySales,
projectedDemand,
suggestedReplenishment,
daysOfCover,
colorCount: colors.size,
sizeCount: sizes.size
};
}, [groupRows]);
const colorBreakdown = useMemo(() => buildBreakdown(groupRows, 'color'), [groupRows]);
const sizeBreakdown = useMemo(() => buildBreakdown(groupRows, 'size'), [groupRows]);
const replenishmentDrivers = useMemo(() => (
groupRows
.filter(row => row.suggestedReplenishment > 0)
.sort((a, b) => b.suggestedReplenishment - a.suggestedReplenishment)
.slice(0, 5)
), [groupRows]);
const isRefreshing = isLoading && products.length > 0;
const totalPages = Math.ceil(groupRows.length / itemsPerPage);
const safeCurrentPage = Math.min(currentPage, totalPages || 1);
const startIndex = (safeCurrentPage - 1) * itemsPerPage;
const paginatedRows = groupRows.slice(startIndex, startIndex + itemsPerPage);
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={(range) => {
setDateRange(range);
setCurrentPage(1);
}}
/>
</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="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
<div className="mb-4 flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
<div>
<h2 className="text-base font-bold text-dark-text">Contexto de reposição</h2>
<p className="mt-1 text-sm font-medium text-dark-muted">
Projeção para {REPLENISHMENT_TARGET_DAYS} dias usando vendas do período, estoque atual e OP aberta quando encontrada.
</p>
</div>
<span className={`w-fit rounded-full border px-3 py-1 text-xs font-bold ${totals.suggestedReplenishment > 0 ? 'border-red-400/30 bg-red-400/10 text-red-300' : 'border-emerald-400/30 bg-emerald-400/10 text-emerald-300'}`}>
{totals.suggestedReplenishment > 0 ? 'Com necessidade' : 'Coberto'}
</span>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-4">
<div className="rounded-xl border border-dark-border bg-dark-input/50 p-3">
<p className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">Sugestão</p>
<p className={`mt-2 text-2xl font-bold ${totals.suggestedReplenishment > 0 ? 'text-red-300' : 'text-emerald-300'}`}>
{formatNumber(totals.suggestedReplenishment)} un.
</p>
</div>
<div className="rounded-xl border border-dark-border bg-dark-input/50 p-3">
<p className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">Demanda 30 dias</p>
<p className="mt-2 text-2xl font-bold text-dark-text">{formatNumber(totals.projectedDemand, 1)} un.</p>
</div>
<div className="rounded-xl border border-dark-border bg-dark-input/50 p-3">
<p className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">Disponível</p>
<p className="mt-2 text-2xl font-bold text-dark-text">{formatNumber(totals.availableQuantity)} un.</p>
{!!totals.openProductionQuantity && (
<p className="mt-1 text-xs font-semibold text-dark-muted">Inclui OP {formatNumber(totals.openProductionQuantity)} un.</p>
)}
</div>
<div className="rounded-xl border border-dark-border bg-dark-input/50 p-3">
<p className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">Cobertura</p>
<p className="mt-2 text-2xl font-bold text-dark-text">{formatDays(totals.daysOfCover)}</p>
</div>
</div>
<div className="mt-4 rounded-xl border border-dark-border bg-dark-input/35">
<div className="border-b border-dark-border px-4 py-3">
<h3 className="text-xs font-bold uppercase tracking-widest text-dark-muted">Principais drivers</h3>
</div>
{replenishmentDrivers.length ? (
<div className="divide-y divide-dark-border">
{replenishmentDrivers.map(row => (
<div key={row.id} className="grid grid-cols-[1fr_auto] gap-4 px-4 py-3">
<div className="min-w-0">
<p className="truncate text-sm font-bold text-dark-text" title={row.name}>{row.name}</p>
<div className="mt-1 flex flex-wrap items-center gap-2">
<ProductColorBadge label={row.color} className="max-w-[8rem]" />
<span className="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 || 'Sem tamanho'}
</span>
<span className="text-xs font-semibold text-dark-muted">
Cobertura {formatDays(row.daysOfCover)}
</span>
</div>
</div>
<div className="text-right">
<p className="text-sm font-bold text-red-300">{formatNumber(row.suggestedReplenishment)} un.</p>
<p className="mt-1 text-[10px] font-semibold text-dark-muted">sugerido</p>
</div>
</div>
))}
</div>
) : (
<div className="px-4 py-6 text-sm font-semibold text-dark-muted">
Nenhum SKU do grupo está abaixo da cobertura projetada.
</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="flex flex-col gap-2 border-b border-zinc-100 px-6 py-4 dark:border-dark-border md:flex-row md:items-end md:justify-between">
<div>
<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>
<span className="text-xs font-bold uppercase tracking-widest text-zinc-400 dark:text-dark-muted">
{formatNumber(groupRows.length)} SKUs
</span>
</div>
<div className="overflow-x-auto">
<table className="w-full min-w-[1100px] table-fixed text-left text-sm">
<colgroup>
<col className="w-[150px]" />
<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">
{paginatedRows.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">
<ProductColorBadge label={row.color} className="max-w-[8rem]" />
</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 min-w-0 items-center gap-2">
<Package className="h-3.5 w-3.5 shrink-0 text-zinc-400 dark:text-dark-muted" />
<span className="min-w-0 font-bold tabular-nums 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">
<div className="flex justify-end gap-2">
<Link
to={buildSkuEditPath({ sku: row.id, name: row.name, color: row.color, size: row.size })}
className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-dark-input text-dark-text transition-colors hover:bg-dark-border"
title={`Editar SKU ${row.id}`}
aria-label={`Editar SKU ${row.id}`}
>
<Pencil className="h-3.5 w-3.5" />
</Link>
<Link
to={`/products/${row.id}`}
className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-brand-primary/10 text-brand-primary transition-opacity hover:opacity-80"
title={`Ver SKU ${row.id}`}
aria-label={`Ver SKU ${row.id}`}
>
<Eye className="h-3.5 w-3.5" />
</Link>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<PaginationControls
totalItems={groupRows.length}
currentPage={safeCurrentPage}
totalPages={totalPages}
pageSize={itemsPerPage}
pageSizeOptions={[10, 20, 50, 100]}
itemLabel="SKUs"
pageSizeLabel="SKUs por página"
startIndex={startIndex}
endIndex={Math.min(startIndex + itemsPerPage, groupRows.length)}
onPageChange={setCurrentPage}
onPageSizeChange={(pageSize) => {
setItemsPerPage(pageSize);
setCurrentPage(1);
}}
/>
</div>
</div>
</div>
);
};
export default ProductGroupDetails;