import { useEffect, useMemo, useState } from 'react'; import { Link, useOutletContext, useSearchParams } from 'react-router-dom'; import { AlertTriangle, CheckCircle2, Download, Package, Search, TrendingUp } from 'lucide-react'; import DateRangePicker from '../components/DateRangePicker'; import PaginationControls from '../components/PaginationControls'; import RefreshStatus from '../components/RefreshStatus'; import { buildOpenProductionByProductId } from '../analytics/cutting'; import type { DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types'; import { exportToCSV, fetchProductAnalytics, fetchProductionOrders } from '../dataService'; import { encodeProductGroupKey, parseProductName, sortProductSizes } from '../productParsing'; import { formatColorLabel } from '../displayFormatters'; type ReplenishmentStatus = 'need' | 'covered' | 'no_sales' | 'no_stock'; type ReplenishmentFilter = 'all' | ReplenishmentStatus; type ReplenishmentSort = 'need_desc' | 'need_asc' | 'demand_desc' | 'stock_asc' | 'coverage_asc' | 'sold_desc' | 'name_asc'; type ReplenishmentView = 'sku' | 'group'; type ReplenishmentRow = ProductAnalyticsItem & { dailySales: number; projectedDemand: number; suggestedQuantity: number; openProductionQuantity: number; availableQuantity: number; daysOfCover: number | null; status: ReplenishmentStatus; statusLabel: string; baseName: string; color: string; size: string; productIds: string[]; productCount: number; sizes: string[]; }; const statusStyles: Record = { need: { label: 'Repor', className: 'border-red-400/35 bg-red-400/10 text-red-300', dotClass: 'bg-red-400' }, no_stock: { label: 'Sem estoque', className: 'border-zinc-500/30 bg-zinc-500/10 text-zinc-300', dotClass: 'bg-zinc-400' }, covered: { label: 'Coberto', 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 filterOptions: Array<{ value: ReplenishmentFilter; label: string }> = [ { value: 'need', label: 'Com necessidade' }, { value: 'all', label: 'Todos' }, { value: 'no_stock', label: 'Sem estoque' }, { value: 'covered', label: 'Cobertos' }, { value: 'no_sales', label: 'Sem venda' } ]; const coverageTargetOptions = [7, 15, 30, 60]; 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 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 allProductionOrdersRange = { start: new Date(2000, 0, 1), end: new Date(2100, 11, 31) }; const ReplenishmentSkeleton = () => (
{[0, 1, 2, 3, 4, 5, 6, 7].map(item => (
))}
{[0, 1, 2, 3, 4, 5, 6, 7].map(row => (
))}
); const Replenishment = () => { const { dateRange, setDateRange } = useOutletContext<{ dateRange: DateRange, setDateRange: (range: DateRange) => void }>(); const [searchParams] = useSearchParams(); const [products, setProducts] = useState([]); const [productionOrders, setProductionOrders] = useState([]); const [isLoading, setIsLoading] = useState(true); const [searchTerm, setSearchTerm] = useState(''); const [statusFilter, setStatusFilter] = useState(() => { const filter = searchParams.get('status') as ReplenishmentFilter | null; return filter && filterOptions.some(option => option.value === filter) ? filter : 'need'; }); const [sortBy, setSortBy] = useState('need_desc'); const [viewMode, setViewMode] = useState(() => searchParams.get('view') === 'group' ? 'group' : 'sku'); const [targetCoverageDays, setTargetCoverageDays] = useState(30); const [currentPage, setCurrentPage] = useState(1); const [itemsPerPage, setItemsPerPage] = useState(10); 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 allRows = useMemo(() => { const rangeDays = getRangeDays(dateRange); return products.map(product => { const dailySales = product.quantitySold / rangeDays; const projectedDemand = dailySales * targetCoverageDays; const openProductionQuantity = openProductionByProductId[product.id] || 0; const availableQuantity = product.stock + openProductionQuantity; const rawNeed = projectedDemand - availableQuantity; const suggestedQuantity = Math.max(0, Math.ceil(rawNeed)); const daysOfCover = dailySales > 0 ? availableQuantity / dailySales : null; const status: ReplenishmentStatus = availableQuantity <= 0 ? 'no_stock' : dailySales <= 0 ? 'no_sales' : suggestedQuantity > 0 ? 'need' : 'covered'; const metadata = parseProductName(product.name); return { ...product, dailySales, projectedDemand, suggestedQuantity, openProductionQuantity, availableQuantity, daysOfCover, status, statusLabel: statusStyles[status].label, baseName: metadata.baseName, color: metadata.color, size: metadata.size, productIds: [product.id], productCount: 1, sizes: metadata.size ? [metadata.size] : [] }; }); }, [dateRange, openProductionByProductId, products, targetCoverageDays]); const groupedRows = useMemo(() => { const groups = new Map(); allRows.forEach(row => { const key = `${row.baseName.toLowerCase()}::${row.color.toLowerCase()}`; const group = groups.get(key) || []; group.push(row); groups.set(key, group); }); return Array.from(groups.values()).map(group => { const first = group[0]; const quantitySold = group.reduce((total, row) => total + row.quantitySold, 0); const revenue = group.reduce((total, row) => total + row.revenue, 0); const stock = group.reduce((total, row) => total + row.stock, 0); const openProductionQuantity = group.reduce((total, row) => total + row.openProductionQuantity, 0); const availableQuantity = group.reduce((total, row) => total + row.availableQuantity, 0); const dailySales = group.reduce((total, row) => total + row.dailySales, 0); const projectedDemand = group.reduce((total, row) => total + row.projectedDemand, 0); const suggestedQuantity = group.reduce((total, row) => total + row.suggestedQuantity, 0); const orderLineCount = group.reduce((total, row) => total + row.orderLineCount, 0); const daysOfCover = dailySales > 0 ? availableQuantity / dailySales : null; const status: ReplenishmentStatus = availableQuantity <= 0 ? 'no_stock' : dailySales <= 0 ? 'no_sales' : suggestedQuantity > 0 ? 'need' : 'covered'; const sizes = sortProductSizes(Array.from(new Set(group.flatMap(row => row.sizes)))); const productIds = group.map(row => row.id); const name = first.color ? `${first.baseName} · ${formatColorLabel(first.color)}` : first.baseName; return { ...first, id: productIds[0], name, quantitySold, revenue, stock, openProductionQuantity, availableQuantity, orderLineCount, dailySales, projectedDemand, suggestedQuantity, daysOfCover, status, statusLabel: statusStyles[status].label, productIds, productCount: group.length, sizes, lastPrice: group.length ? revenue / Math.max(1, quantitySold) : first.lastPrice }; }); }, [allRows]); const activeRows = viewMode === 'group' ? groupedRows : allRows; const filteredRows = useMemo(() => { const normalizedSearch = searchTerm.trim().toLowerCase(); const searchedRows = normalizedSearch ? activeRows.filter(row => row.name.toLowerCase().includes(normalizedSearch) || row.id.toLowerCase().includes(normalizedSearch) || row.productIds.some(id => id.toLowerCase().includes(normalizedSearch)) ) : activeRows; const statusRows = statusFilter === 'all' ? searchedRows : searchedRows.filter(row => ( statusFilter === 'need' ? row.suggestedQuantity > 0 : row.status === statusFilter )); return [...statusRows].sort((a, b) => { switch (sortBy) { case 'need_asc': return a.suggestedQuantity - b.suggestedQuantity; case 'demand_desc': return b.projectedDemand - a.projectedDemand; case 'stock_asc': return a.stock - b.stock; case 'coverage_asc': return (a.daysOfCover ?? Number.POSITIVE_INFINITY) - (b.daysOfCover ?? Number.POSITIVE_INFINITY); case 'sold_desc': return b.quantitySold - a.quantitySold; case 'name_asc': return a.name.localeCompare(b.name, 'pt-BR'); case 'need_desc': default: return b.suggestedQuantity - a.suggestedQuantity; } }); }, [activeRows, searchTerm, sortBy, statusFilter]); const totalPages = Math.ceil(filteredRows.length / itemsPerPage); const safeCurrentPage = Math.min(currentPage, totalPages || 1); const startIndex = (safeCurrentPage - 1) * itemsPerPage; const paginatedRows = filteredRows.slice(startIndex, startIndex + itemsPerPage); const isRefreshing = isLoading && products.length > 0; const needRows = activeRows.filter(row => row.suggestedQuantity > 0); const totalSuggestedQuantity = needRows.reduce((total, row) => total + row.suggestedQuantity, 0); const projectedDemand = activeRows.reduce((total, row) => total + row.projectedDemand, 0); const totalStock = activeRows.reduce((total, row) => total + row.stock, 0); return (

Necessidade de Reposição

O período selecionado define a base de vendas; a cobertura define quantos dias de estoque sugerir.

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

Produtos a repor

{formatNumber(needRows.length)}

{viewMode === 'group' ? 'Grupos' : 'SKUs'} abaixo da demanda projetada

Sugestão total

{formatNumber(totalSuggestedQuantity)}

Unidades para cobrir {targetCoverageDays} dias

Demanda projetada

{formatNumber(projectedDemand)}

Pelo ritmo do período selecionado

{viewMode === 'group' ? 'Grupos cobertos' : 'Produtos cobertos'}

{formatNumber(activeRows.filter(row => row.status === 'covered').length)}

{formatNumber(totalStock)} unidades em estoque

{[ { key: 'sku' as const, label: 'SKU' }, { key: 'group' as const, label: 'Grupo' } ].map(view => ( ))}
{ setSearchTerm(event.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" />
{isLoading && products.length === 0 ? ( ) : (
{paginatedRows.map(row => { const style = statusStyles[row.status]; return ( ); })}
{viewMode === 'group' ? 'SKUs' : 'ID Produto'} {viewMode === 'group' ? 'Grupo / cor' : 'Descrição'} Status Demanda proj. Estoque Disponível Sugestão reposição Cobertura Ações
{viewMode === 'group' ? `${row.productCount} SKUs` : `#${row.id}`}
{row.name}
{viewMode === 'group' && row.sizes.length ? `Tamanhos: ${row.sizes.join(', ')} · ` : ''} Média: {formatNumber(row.dailySales, 2)} un./dia · Vendido: {formatNumber(row.quantitySold)} un.
{style.label} {formatNumber(row.projectedDemand, 1)} un. {formatNumber(row.stock)} un. {formatNumber(row.availableQuantity)} un. {!!row.openProductionQuantity && ( OP {formatNumber(row.openProductionQuantity)} )} 0 ? 'font-bold text-red-300' : 'font-bold text-emerald-300'}> {formatNumber(row.suggestedQuantity)} un. {formatDays(row.daysOfCover)} {viewMode === 'group' ? 'Ver grupo' : 'Ver produto'}
{!filteredRows.length && (

Nenhum produto encontrado.

Ajuste os filtros, a busca ou o período selecionado.

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