import { useEffect, useMemo, useRef, useState } from 'react'; import { Link, useOutletContext } from 'react-router-dom'; import { Download, Eye, Filter, Package, PackageCheck, Pencil, Search, TrendingDown, X } from 'lucide-react'; import { buildSkuEditPath } from '../catalogLinks'; import DateRangePicker from '../components/DateRangePicker'; import PaginationControls from '../components/PaginationControls'; import RefreshStatus from '../components/RefreshStatus'; import type { DateRange, ProductAnalyticsItem } from '../types'; import { exportToCSV, fetchProductAnalytics } from '../dataService'; import { encodeProductGroupKey, parseProductName, sortProductSizes } from '../productParsing'; import { formatColorLabel } from '../displayFormatters'; type StockRisk = 'rupture' | 'critical' | 'attention' | 'monitor' | 'healthy' | 'no_sales'; type StockStatusFilter = 'all' | StockRisk; type StockQuantityFilter = 'all' | 'zero' | 'positive' | 'low' | 'high'; type SalesFilter = 'all' | 'sold' | 'not_sold'; type CoverageFilter = 'all' | 'up_to_7' | 'up_to_14' | 'up_to_30' | 'over_30' | 'none'; type ProductSortOption = 'sold_desc' | 'sold_asc' | 'stock_priority' | 'revenue_desc' | 'revenue_asc' | 'stock_asc' | 'stock_desc' | 'coverage_asc' | 'coverage_desc' | 'name_asc'; type ProductViewMode = 'sku' | 'group'; type ProductRow = ProductAnalyticsItem & { dailySales: number; daysOfCover: number | null; risk: StockRisk; riskLabel: string; baseName: string; color: string; size: string; productIds: string[]; skuCount: number; colors: string[]; sizes: string[]; topColor: string; topSize: string; groupKey: string; }; const riskStyles: Record = { rupture: { label: 'Sem estoque', className: 'border-zinc-500/30 bg-zinc-500/10 text-zinc-300', dotClass: 'bg-zinc-400' }, critical: { label: 'Repor agora', className: 'border-red-400/35 bg-red-400/10 text-red-300', dotClass: 'bg-red-400' }, attention: { label: 'Atenção', className: 'border-amber-400/35 bg-amber-400/10 text-amber-300', dotClass: 'bg-amber-400' }, monitor: { label: 'Monitorar', className: 'border-sky-400/35 bg-sky-400/10 text-sky-300', dotClass: 'bg-sky-400' }, healthy: { label: 'Saudável', className: 'border-emerald-400/35 bg-emerald-400/10 text-emerald-300', dotClass: 'bg-emerald-400' }, no_sales: { label: 'Sem venda', className: 'border-dark-border bg-dark-input text-dark-muted', dotClass: 'bg-dark-muted' } }; const stockStatusOptions: Array<{ value: StockStatusFilter; label: string }> = [ { value: 'all', label: 'Todos' }, { value: 'rupture', label: 'Sem estoque' }, { value: 'critical', label: 'Repor agora' }, { value: 'attention', label: 'Atenção' }, { value: 'monitor', label: 'Monitorar' }, { value: 'healthy', label: 'Saudável' }, { value: 'no_sales', label: 'Sem venda' } ]; const stockPriority: Record = { rupture: 0, critical: 1, attention: 2, monitor: 3, healthy: 4, no_sales: 5 }; const filterSelectClassName = "w-full h-9 bg-dark-input border border-dark-border text-dark-text text-sm rounded-lg px-3 focus:outline-none focus:border-brand-primary transition-colors cursor-pointer"; 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 classifyStockRisk = (stock: number, dailySales: number): StockRisk => { if (stock <= 0) return 'rupture'; if (dailySales <= 0) return 'no_sales'; const daysOfCover = stock / dailySales; if (daysOfCover <= 7) return 'critical'; if (daysOfCover <= 14) return 'attention'; if (daysOfCover <= 30) return 'monitor'; return 'healthy'; }; const formatNumber = (value: number, maximumFractionDigits = 0) => ( new Intl.NumberFormat('pt-BR', { maximumFractionDigits }).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 ProductTableSkeleton = () => (
{[0, 1, 2, 3, 4, 5, 6, 7, 8].map(item => (
))}
{[0, 1, 2, 3, 4, 5, 6, 7].map(row => (
))}
); const Products = () => { const { dateRange, setDateRange } = useOutletContext<{ dateRange: DateRange, setDateRange: (range: DateRange) => void }>(); const [searchTerm, setSearchTerm] = useState(''); const [sortBy, setSortBy] = useState('sold_desc'); const [stockStatusFilter, setStockStatusFilter] = useState('all'); const [stockQuantityFilter, setStockQuantityFilter] = useState('all'); const [salesFilter, setSalesFilter] = useState('all'); const [coverageFilter, setCoverageFilter] = useState('all'); const [viewMode, setViewMode] = useState('sku'); const [isFilterMenuOpen, setIsFilterMenuOpen] = useState(false); const filterMenuRef = useRef(null); const [productAnalytics, setProductAnalytics] = useState([]); const [isLoading, setIsLoading] = useState(true); const [currentPage, setCurrentPage] = useState(1); const [itemsPerPage, setItemsPerPage] = useState(10); useEffect(() => { let isMounted = true; const loadProducts = async () => { setIsLoading(true); const products = await fetchProductAnalytics(dateRange); if (isMounted) { setProductAnalytics(products); setIsLoading(false); } }; void loadProducts(); return () => { isMounted = false; }; }, [dateRange]); useEffect(() => { if (!isFilterMenuOpen) return; const handlePointerDown = (event: PointerEvent) => { if (!filterMenuRef.current?.contains(event.target as Node)) { setIsFilterMenuOpen(false); } }; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { setIsFilterMenuOpen(false); } }; document.addEventListener('pointerdown', handlePointerDown); document.addEventListener('keydown', handleKeyDown); return () => { document.removeEventListener('pointerdown', handlePointerDown); document.removeEventListener('keydown', handleKeyDown); }; }, [isFilterMenuOpen]); const productsData = useMemo(() => { const days = getRangeDays(dateRange); const normalizedSearch = searchTerm.trim().toLowerCase(); const skuRows = productAnalytics.map(product => { const dailySales = product.quantitySold / days; const risk = classifyStockRisk(product.stock, dailySales); const style = riskStyles[risk]; const metadata = parseProductName(product.name); return { ...product, dailySales, daysOfCover: dailySales > 0 ? product.stock / dailySales : null, risk, riskLabel: style.label, baseName: metadata.baseName, color: metadata.color, size: metadata.size, productIds: [product.id], skuCount: 1, colors: metadata.color ? [metadata.color] : [], sizes: metadata.size ? [metadata.size] : [], topColor: metadata.color || '-', topSize: metadata.size || '-', groupKey: encodeProductGroupKey(metadata.baseName) }; }); const groups = new Map(); skuRows.forEach(product => { const key = product.baseName.toLowerCase(); const group = groups.get(key) || []; group.push(product); groups.set(key, group); }); const groupedRows = Array.from(groups.values()).map(group => { const first = group[0]; const quantitySold = group.reduce((total, product) => total + product.quantitySold, 0); const revenue = group.reduce((total, product) => total + product.revenue, 0); const stock = group.reduce((total, product) => total + product.stock, 0); const orderLineCount = group.reduce((total, product) => total + product.orderLineCount, 0); const dailySales = quantitySold / days; const daysOfCover = dailySales > 0 ? stock / dailySales : null; const risk = classifyStockRisk(stock, dailySales); const style = riskStyles[risk]; const colorTotals = new Map(); const sizeTotals = new Map(); group.forEach(product => { if (product.color) { colorTotals.set(product.color, (colorTotals.get(product.color) || 0) + product.quantitySold); } if (product.size) { sizeTotals.set(product.size, (sizeTotals.get(product.size) || 0) + product.quantitySold); } }); const topColor = [...colorTotals.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] || '-'; const topSize = [...sizeTotals.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] || '-'; const colors = [...colorTotals.keys()].sort((a, b) => a.localeCompare(b, 'pt-BR')); const sizes = sortProductSizes([...sizeTotals.keys()]); return { ...first, id: first.groupKey, name: first.baseName, quantitySold, revenue, orderLineCount, stock, dailySales, daysOfCover, risk, riskLabel: style.label, productIds: group.map(product => product.id), skuCount: group.length, colors, sizes, topColor, topSize, lastPrice: quantitySold > 0 ? revenue / quantitySold : first.lastPrice }; }); const activeRows = viewMode === 'group' ? groupedRows : skuRows; const filteredProducts = normalizedSearch ? activeRows.filter(product => product.name.toLowerCase().includes(normalizedSearch) || product.id.toLowerCase().includes(normalizedSearch) || product.productIds.some(id => id.toLowerCase().includes(normalizedSearch)) || product.colors.some(color => color.toLowerCase().includes(normalizedSearch)) || product.sizes.some(size => size.toLowerCase().includes(normalizedSearch)) ) : activeRows; const detailedFilteredProducts = filteredProducts.filter(product => { const matchesStatus = stockStatusFilter === 'all' || product.risk === stockStatusFilter; const matchesStockQuantity = stockQuantityFilter === 'all' || (stockQuantityFilter === 'zero' && product.stock <= 0) || (stockQuantityFilter === 'positive' && product.stock > 0) || (stockQuantityFilter === 'low' && product.stock > 0 && product.stock <= 10) || (stockQuantityFilter === 'high' && product.stock > 100); const matchesSales = salesFilter === 'all' || (salesFilter === 'sold' && product.quantitySold > 0) || (salesFilter === 'not_sold' && product.quantitySold === 0); const matchesCoverage = coverageFilter === 'all' || (coverageFilter === 'none' && product.daysOfCover === null) || (coverageFilter === 'up_to_7' && product.daysOfCover !== null && product.daysOfCover <= 7) || (coverageFilter === 'up_to_14' && product.daysOfCover !== null && product.daysOfCover > 7 && product.daysOfCover <= 14) || (coverageFilter === 'up_to_30' && product.daysOfCover !== null && product.daysOfCover > 14 && product.daysOfCover <= 30) || (coverageFilter === 'over_30' && product.daysOfCover !== null && product.daysOfCover > 30); return matchesStatus && matchesStockQuantity && matchesSales && matchesCoverage; }); return detailedFilteredProducts.sort((a, b) => { switch (sortBy) { case 'sold_asc': return a.quantitySold - b.quantitySold; case 'stock_priority': { const priorityDiff = stockPriority[a.risk] - stockPriority[b.risk]; if (priorityDiff !== 0) return priorityDiff; return b.quantitySold - a.quantitySold; } case 'revenue_desc': return b.revenue - a.revenue; case 'revenue_asc': return a.revenue - b.revenue; case 'stock_asc': return a.stock - b.stock; case 'stock_desc': return b.stock - a.stock; case 'coverage_asc': return (a.daysOfCover ?? Number.POSITIVE_INFINITY) - (b.daysOfCover ?? Number.POSITIVE_INFINITY); case 'coverage_desc': return (b.daysOfCover ?? -1) - (a.daysOfCover ?? -1); case 'name_asc': return a.name.localeCompare(b.name, 'pt-BR'); case 'sold_desc': default: return b.quantitySold - a.quantitySold; } }); }, [coverageFilter, dateRange, productAnalytics, salesFilter, searchTerm, sortBy, stockQuantityFilter, stockStatusFilter, viewMode]); const activeFilterCount = (stockStatusFilter === 'all' ? 0 : 1) + (stockQuantityFilter === 'all' ? 0 : 1) + (salesFilter === 'all' ? 0 : 1) + (coverageFilter === 'all' ? 0 : 1); const hasActiveFilters = activeFilterCount > 0; const resetProductFilters = () => { setStockStatusFilter('all'); setStockQuantityFilter('all'); setSalesFilter('all'); setCoverageFilter('all'); setCurrentPage(1); }; const totalPages = Math.ceil(productsData.length / itemsPerPage); const safeCurrentPage = Math.min(currentPage, totalPages || 1); const startIndex = (safeCurrentPage - 1) * itemsPerPage; const paginatedData = productsData.slice(startIndex, startIndex + itemsPerPage); const isRefreshing = isLoading && productAnalytics.length > 0; const formatCurrency = (value: number) => { return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value); }; return (

Produtos

Performance de vendas, saldo e cobertura por produto.

{ setDateRange(range); setCurrentPage(1); }} />
{[ { key: 'sku' as const, label: 'SKU' }, { key: 'group' as const, label: 'Grupo' } ].map(view => ( ))}
{ setSearchTerm(e.target.value); setCurrentPage(1); }} className="w-full bg-dark-input border border-dark-border text-dark-text rounded-xl pl-10 pr-4 py-2.5 focus:outline-none focus:border-brand-primary hover:border-brand-primary transition-colors" />
{isFilterMenuOpen && (

Filtros

)}
{isLoading && productAnalytics.length === 0 ? ( ) : (
{paginatedData.map((product) => { const style = riskStyles[product.risk]; return ( ); })}
{viewMode === 'group' ? 'SKUs' : 'ID Produto'} {viewMode === 'group' ? 'Grupo' : 'Descrição'} Status Total Vendido Estoque Média Diária Cobertura estimada Receita Gerada Ações
{viewMode === 'group' ? `${product.skuCount} SKUs` : `#${product.id}`}
{product.name}
{viewMode === 'group' ? `Cor principal: ${formatColorLabel(product.topColor)} · Tam. principal: ${product.topSize} · ${product.colors.length} cores · ${product.sizes.length} tamanhos` : `Preço Atual: ${formatCurrency(product.lastPrice)}`}
{style.label}
{formatNumber(product.quantitySold)} un.
{formatNumber(product.stock)} un. {formatNumber(product.dailySales, 2)} un./dia {formatDays(product.daysOfCover)} {formatCurrency(product.revenue)}
{viewMode === 'sku' && ( )}
{!productsData.length && (

Nenhum produto encontrado.

Ajuste a busca ou o período selecionado.

)} { setItemsPerPage(pageSize); setCurrentPage(1); }} />
)}
); }; export default Products;