592 lines
27 KiB
TypeScript
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;
|