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(); 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 (

{title}

{subtitle}

{type === 'color' ? ( ) : ( )}
{rows.length === 0 ? (
Sem dados para este grupo.
) : (
{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 (
{type === 'color' ? : } {type === 'size' && row.label !== 'Sem tamanho' ? `Tam. ${row.label}` : displayLabel}
{formatNumber(row.quantitySold)} un.
{row.skuCount} SKUs
); })} {hiddenCount > 0 && (
+{formatNumber(hiddenCount)} itens fora do top {BREAKDOWN_LIMIT}
)}
)}
); }; const ProductGroupDetailsSkeleton = () => (
{[0, 1, 2, 3].map(item => (
))}
); const ProductGroupDetails = () => { const { groupKey } = useParams<{ groupKey: string }>(); const { dateRange, setDateRange } = useOutletContext<{ dateRange: DateRange, setDateRange: (range: DateRange) => void }>(); const [products, setProducts] = useState([]); const [productionOrders, setProductionOrders] = useState([]); 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(() => { 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 ; } if (!groupName || groupRows.length === 0) { return (

Grupo de produtos não encontrado.

Voltar para produtos
); } return (

Grupo · {formatNumber(groupRows.length)} SKUs · {formatNumber(totals.colorCount)} cores · {formatNumber(totals.sizeCount)} tamanhos

{groupName}

{ setDateRange(range); setCurrentPage(1); }} />

Unidades vendidas

{formatNumber(totals.totalSold)}

Receita total

{formatCurrency(totals.totalRevenue)}

Estoque

{formatNumber(totals.totalStock)}

Cobertura estimada

{formatDays(totals.daysOfCover)}

Contexto de reposição

Projeção para {REPLENISHMENT_TARGET_DAYS} dias usando vendas do período, estoque atual e OP aberta quando encontrada.

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'}

Sugestão

0 ? 'text-red-300' : 'text-emerald-300'}`}> {formatNumber(totals.suggestedReplenishment)} un.

Demanda 30 dias

{formatNumber(totals.projectedDemand, 1)} un.

Disponível

{formatNumber(totals.availableQuantity)} un.

{!!totals.openProductionQuantity && (

Inclui OP {formatNumber(totals.openProductionQuantity)} un.

)}

Cobertura

{formatDays(totals.daysOfCover)}

Principais drivers

{replenishmentDrivers.length ? (
{replenishmentDrivers.map(row => (

{row.name}

{row.size || 'Sem tamanho'} Cobertura {formatDays(row.daysOfCover)}

{formatNumber(row.suggestedReplenishment)} un.

sugerido

))}
) : (
Nenhum SKU do grupo está abaixo da cobertura projetada.
)}

Variações do grupo

SKUs, cores e tamanhos que formam este grupo.

{formatNumber(groupRows.length)} SKUs
{paginatedRows.map(row => ( ))}
ID Produto Descrição Cor Tamanho Vendido Estoque Média diária Receita Ações
#{row.id}
{row.name}
Preço Atual: {formatCurrency(row.lastPrice)}
{row.size || '-'}
{formatNumber(row.quantitySold)} un.
{formatNumber(row.stock)} un. {formatNumber(row.dailySales, 2)} un./dia {formatCurrency(row.revenue)}
{ setItemsPerPage(pageSize); setCurrentPage(1); }} />
); }; export default ProductGroupDetails;