diff --git a/src/pages/Cutting.tsx b/src/pages/Cutting.tsx index 75afbce..cfd0ca0 100644 --- a/src/pages/Cutting.tsx +++ b/src/pages/Cutting.tsx @@ -26,7 +26,7 @@ const familyOptions: Array<{ value: CutFamilyKey | 'all'; label: string }> = [ const filterOptions: Array<{ value: CutFilter; label: string }> = [ { value: 'need', label: 'Com necessidade' }, { value: 'all', label: 'Todos' }, - { value: 'issues', label: 'Pendências' }, + { value: 'issues', label: 'Dados pendentes' }, { value: 'covered', label: 'Sem necessidade' } ]; @@ -45,7 +45,7 @@ const issueHelp: Record = { }; const correctionFilterOptions: Array<{ value: CorrectionIssueFilter; label: string }> = [ - { value: 'all', label: 'Todas pendências' }, + { value: 'all', label: 'Todos os dados pendentes' }, { value: 'missing_family_rule', label: issueLabels.missing_family_rule }, { value: 'missing_color', label: issueLabels.missing_color }, { value: 'missing_size', label: issueLabels.missing_size } @@ -368,7 +368,7 @@ const Cutting = () => { 'Rendimento un/rolo': row.family.unitsPerRoll || '', 'Rolos estimados': row.estimatedRolls || '', 'Cobertura': row.daysOfCover === null ? '' : row.daysOfCover.toFixed(1).replace('.', ','), - 'Pendências': row.issues.map(issue => issueLabels[issue]).join(' | ') + 'Dados pendentes': row.issues.map(issue => issueLabels[issue]).join(' | ') })), `plano_corte_${new Date().toISOString().split('T')[0]}.csv`); }; @@ -386,7 +386,7 @@ const Cutting = () => { > - {row.issues.length === 1 ? issueLabels[row.issues[0]] : `${row.issues.length} pendências`} + {row.issues.length === 1 ? issueLabels[row.issues[0]] : `${row.issues.length} dados`} ); @@ -627,7 +627,7 @@ const Cutting = () => { ) : (

Nenhuma correção de produto pendente.

-

As pendências restantes, se existirem, são de rendimento por família.

+

Os dados restantes, se existirem, são de rendimento por família.

)} @@ -697,8 +697,8 @@ const Cutting = () => {
-

Pendências do plano

-

Itens que ainda precisam de regra, cor, tamanho ou rendimento.

+

Dados pendentes do plano

+

Dados que bloqueiam o cálculo completo: família, cor, tamanho ou rendimento.

@@ -875,7 +875,12 @@ const Cutting = () => { Estoque Necessidade Rolos - Pendências + + Dados pendentes + Ações diff --git a/src/pages/ProductGroupDetails.tsx b/src/pages/ProductGroupDetails.tsx index 4371013..7152865 100644 --- a/src/pages/ProductGroupDetails.tsx +++ b/src/pages/ProductGroupDetails.tsx @@ -6,14 +6,19 @@ import DateRangePicker from '../components/DateRangePicker'; import PaginationControls from '../components/PaginationControls'; import ProductColorBadge, { ProductColorSwatch, getProductColor } from '../components/ProductColorBadge'; import RefreshStatus from '../components/RefreshStatus'; -import { fetchProductAnalytics } from '../dataService'; +import { buildOpenProductionByProductId } from '../analytics/cutting'; +import { fetchProductAnalytics, fetchProductionOrders } from '../dataService'; import { decodeProductGroupKey, normalizeProductText, parseProductName } from '../productParsing'; -import type { DateRange, ProductAnalyticsItem } from '../types'; +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; }; @@ -26,6 +31,12 @@ type BreakdownRow = { }; 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); @@ -174,6 +185,7 @@ const ProductGroupDetails = () => { 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); @@ -193,10 +205,14 @@ const ProductGroupDetails = () => { const loadProducts = async () => { setIsLoading(true); - const data = await fetchProductAnalytics(dateRange); + const [productData, productionOrderData] = await Promise.all([ + fetchProductAnalytics(dateRange), + fetchProductionOrders(allProductionOrdersRange) + ]); if (isMounted) { - setProducts(data); + setProducts(productData); + setProductionOrders(productionOrderData.orders); setIsLoading(false); } }; @@ -208,6 +224,11 @@ const ProductGroupDetails = () => { }; }, [dateRange]); + const openProductionByProductId = useMemo( + () => buildOpenProductionByProductId(products, productionOrders), + [products, productionOrders] + ); + const groupRows = useMemo(() => { const rangeDays = getRangeDays(dateRange); const normalizedGroupName = normalizeProductText(groupName).toLowerCase(); @@ -216,6 +237,10 @@ const ProductGroupDetails = () => { .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, @@ -223,19 +248,27 @@ const ProductGroupDetails = () => { size: metadata.size, baseName: metadata.baseName, dailySales, - daysOfCover: dailySales > 0 ? product.stock / dailySales : null + 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, products]); + }, [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 daysOfCover = dailySales > 0 ? totalStock / dailySales : null; + 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)); @@ -243,7 +276,11 @@ const ProductGroupDetails = () => { totalSold, totalRevenue, totalStock, + openProductionQuantity, + availableQuantity, dailySales, + projectedDemand, + suggestedReplenishment, daysOfCover, colorCount: colors.size, sizeCount: sizes.size @@ -252,6 +289,12 @@ const ProductGroupDetails = () => { 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); @@ -346,6 +389,78 @@ const ProductGroupDetails = () => {
+
+
+
+

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. +
+ )} +
+
+