From 3f8e5eeb0cd57c4cf06cff8f2966fd65fcf9cf9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Tue, 4 Aug 2026 15:09:57 -0300 Subject: [PATCH] Centralize planning data exceptions --- src/pages/PlanningIssues.tsx | 295 ++++++++++++++++++++++++++++++++++- src/pages/Supplies.tsx | 8 +- 2 files changed, 295 insertions(+), 8 deletions(-) diff --git a/src/pages/PlanningIssues.tsx b/src/pages/PlanningIssues.tsx index c6fb8d2..ad0b399 100644 --- a/src/pages/PlanningIssues.tsx +++ b/src/pages/PlanningIssues.tsx @@ -7,13 +7,23 @@ import ProductTypeBadge from '../components/ProductTypeBadge'; import RefreshStatus from '../components/RefreshStatus'; import SkuPlanningModal from '../components/SkuPlanningModal'; import { classifyCutFamily, CUT_FAMILY_RULES, type CutFamilyKey } from '../analytics/cutting'; -import { fetchCuttingSettings, fetchProductAnalytics, saveCuttingSettings } from '../dataService'; +import { fetchCuttingSettings, fetchProductAnalytics, fetchStock, fetchSupplySummary, saveCuttingSettings } from '../dataService'; +import { buildConsumptionReferencePath } from '../catalogLinks'; import { getProductTypeConfig, resolveProductType, type ProductTypeKey } from '../productClassification'; import { parseProductName } from '../productParsing'; -import type { CutProductOverride, CuttingSettings, DateRange, ProductAnalyticsItem } from '../types'; +import type { CutProductOverride, CuttingSettings, DateRange, ProductAnalyticsItem, StockData, SupplySummary } from '../types'; type IssueKey = 'review_type' | 'missing_cut_family' | 'missing_color' | 'missing_size' | 'missing_yield'; type IssueFilter = 'all' | IssueKey; +type ExceptionQueue = 'negative_stock' | 'missing_composition' | 'cutting_data'; + +type MissingCompositionRow = { + productId: string; + name: string; + quantitySold: number; + stockQuantity: number; + suggestedQuantity: number; +}; type PlanningIssueRow = ProductAnalyticsItem & { productType: ProductTypeKey; @@ -64,6 +74,22 @@ const formatCurrency = (value: number) => ( new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value) ); +const emptySupplySummary: SupplySummary = { + receipts: [], + lots: [], + movements: [], + fabricPlans: [], + purchaseNeeds: [], + stats: { + totalQuantityKg: 0, + activeLots: 0, + rolls: 0, + alerts: 0, + pendingReceipts: 0, + approvedReceipts: 0, + }, +}; + const buildIssues = ( product: ProductAnalyticsItem, settings: CuttingSettings @@ -122,7 +148,10 @@ const PlanningIssues = () => { }>(); const [products, setProducts] = useState([]); const [settings, setSettings] = useState({ familyYields: {}, productOverrides: {} }); + const [supplySummary, setSupplySummary] = useState(emptySupplySummary); + const [stockSnapshot, setStockSnapshot] = useState([]); const [isLoading, setIsLoading] = useState(true); + const [activeQueue, setActiveQueue] = useState('negative_stock'); const [searchTerm, setSearchTerm] = useState(''); const [issueFilter, setIssueFilter] = useState('all'); const [editingProduct, setEditingProduct] = useState(null); @@ -135,14 +164,18 @@ const PlanningIssues = () => { const loadData = async () => { setIsLoading(true); - const [productData, planningSettings] = await Promise.all([ + const [productData, planningSettings, nextSupplySummary, nextStockSnapshot] = await Promise.all([ fetchProductAnalytics(dateRange), - fetchCuttingSettings() + fetchCuttingSettings(), + fetchSupplySummary(), + fetchStock(), ]); if (isMounted) { setProducts(productData); setSettings(planningSettings); + setSupplySummary(nextSupplySummary); + setStockSnapshot(nextStockSnapshot); setIsLoading(false); } }; @@ -187,6 +220,52 @@ const PlanningIssues = () => { return counts; }, [products, settings]); + const negativeStockRows = useMemo(() => ( + stockSnapshot + .filter(item => Number(item.saldo || 0) < 0) + .sort((a, b) => Number(a.saldo) - Number(b.saldo)) + ), [stockSnapshot]); + + const missingCompositionRows = useMemo(() => { + const byProductId = new Map(); + + supplySummary.purchaseNeeds + .filter(need => need.missingReference) + .flatMap(need => need.products || []) + .forEach(product => { + if (!product.productId) return; + const existing = byProductId.get(product.productId); + if (existing) { + existing.quantitySold = Math.max(existing.quantitySold, Number(product.quantitySold || 0)); + existing.stockQuantity = Math.max(existing.stockQuantity, Number(product.stockQuantity || 0)); + existing.suggestedQuantity = Math.max(existing.suggestedQuantity, Number(product.suggestedQuantity || 0)); + return; + } + byProductId.set(product.productId, { + productId: product.productId, + name: product.name, + quantitySold: Number(product.quantitySold || 0), + stockQuantity: Number(product.stockQuantity || 0), + suggestedQuantity: Number(product.suggestedQuantity || 0), + }); + }); + + return Array.from(byProductId.values()) + .sort((a, b) => b.suggestedQuantity - a.suggestedQuantity || b.quantitySold - a.quantitySold); + }, [supplySummary.purchaseNeeds]); + + const normalizedSearch = searchTerm.trim().toLocaleLowerCase('pt-BR'); + const visibleNegativeStockRows = useMemo(() => ( + negativeStockRows.filter(row => ( + !normalizedSearch || `${row.produto_id} ${row.nome}`.toLocaleLowerCase('pt-BR').includes(normalizedSearch) + )) + ), [negativeStockRows, normalizedSearch]); + const visibleMissingCompositionRows = useMemo(() => ( + missingCompositionRows.filter(row => ( + !normalizedSearch || `${row.productId} ${row.name}`.toLocaleLowerCase('pt-BR').includes(normalizedSearch) + )) + ), [missingCompositionRows, normalizedSearch]); + const saveProductOverride = async (productId: string, override: CutProductOverride | null) => { const productOverrides = { ...settings.productOverrides }; if (override) { @@ -206,10 +285,17 @@ const PlanningIssues = () => { } }; - const totalPages = Math.ceil(rows.length / itemsPerPage); + const activeRowCount = activeQueue === 'negative_stock' + ? visibleNegativeStockRows.length + : activeQueue === 'missing_composition' + ? visibleMissingCompositionRows.length + : rows.length; + const totalPages = Math.ceil(activeRowCount / itemsPerPage); const safeCurrentPage = Math.min(currentPage, totalPages || 1); const startIndex = (safeCurrentPage - 1) * itemsPerPage; const paginatedRows = rows.slice(startIndex, startIndex + itemsPerPage); + const paginatedNegativeStockRows = visibleNegativeStockRows.slice(startIndex, startIndex + itemsPerPage); + const paginatedMissingCompositionRows = visibleMissingCompositionRows.slice(startIndex, startIndex + itemsPerPage); const isRefreshing = isLoading && products.length > 0; return ( @@ -218,7 +304,7 @@ const PlanningIssues = () => {

Dados Pendentes

- Fila principal para revisar tipo, cor, tamanho, família e rendimento dos SKUs. + Exceções de estoque, composição e cadastro. Elas ficam visíveis aqui, sem parar compra, corte ou produção.

@@ -241,6 +327,65 @@ const PlanningIssues = () => { +
+ {[ + { + id: 'negative_stock' as const, + label: 'Saldos negativos', + description: 'Estoque informado abaixo de zero', + count: negativeStockRows.length, + tone: 'critical', + icon: AlertTriangle, + }, + { + id: 'missing_composition' as const, + label: 'SKUs sem composição', + description: 'Fora do cálculo de materiais', + count: missingCompositionRows.length, + tone: 'info', + icon: PackageSearch, + }, + { + id: 'cutting_data' as const, + label: 'Cadastro para corte', + description: 'Tipo, cor, tamanho, família e rendimento', + count: Array.from(issueCounts.values()).reduce((total, count) => total + count, 0), + tone: 'attention', + icon: Pencil, + }, + ].map(queue => { + const Icon = queue.icon; + const active = activeQueue === queue.id; + const tone = queue.tone === 'critical' + ? active ? 'border-red-400/45 bg-red-500/[0.07]' : 'border-dark-border bg-dark-card hover:border-red-400/35' + : queue.tone === 'info' + ? active ? 'border-sky-400/45 bg-sky-500/[0.07]' : 'border-dark-border bg-dark-card hover:border-sky-400/35' + : active ? 'border-amber-400/45 bg-amber-500/[0.07]' : 'border-dark-border bg-dark-card hover:border-amber-400/35'; + const iconTone = queue.tone === 'critical' ? 'text-red-300' : queue.tone === 'info' ? 'text-sky-300' : 'text-amber-300'; + return ( + + ); + })} +
+ + {activeQueue === 'cutting_data' ? ( + <>
{issueFilters.filter(option => option.value !== 'all').map(option => { const count = issueCounts.get(option.value as IssueKey) || 0; @@ -422,6 +567,144 @@ const PlanningIssues = () => { )}
)} + + ) : ( +
+
+
+ + { + setSearchTerm(event.target.value); + setCurrentPage(1); + }} + placeholder={activeQueue === 'negative_stock' ? 'Buscar material ou SKU...' : 'Buscar SKU ou produto...'} + className="h-10 w-full rounded-xl border border-dark-border bg-dark-input pl-10 pr-3 text-sm font-semibold text-dark-text outline-none transition-colors placeholder:text-dark-muted focus:border-brand-primary" + /> +
+

+ {formatNumber(activeRowCount)} {activeQueue === 'negative_stock' ? 'materiais para conferir' : 'SKUs fora do cálculo'} +

+
+ + {isLoading && !products.length ? ( + + ) : activeQueue === 'negative_stock' ? ( + visibleNegativeStockRows.length ? ( +
+
+

Saldos negativos

+

São exibidos para conferência, mas contam como zero disponível no planejamento.

+
+
+ + + + + + + + + + + + {paginatedNegativeStockRows.map(row => ( + + + + + + + + ))} + +
SKUMaterialSaldo informadoTratamento no planoAção
#{row.produto_id}

{row.nome}

{formatNumber(Number(row.saldo), 2)} un.0 disponível + + Conferir + +
+
+ { setItemsPerPage(size); setCurrentPage(1); }} + /> +
+ ) : ( +
+ +

Nenhum saldo negativo neste filtro.

+
+ ) + ) : visibleMissingCompositionRows.length ? ( +
+
+

SKUs sem composição

+

Estes produtos continuam operando, mas não geram demanda de material até receberem composição.

+
+
+ + + + + + + + + + + + + {paginatedMissingCompositionRows.map(row => ( + + + + + + + + + ))} + +
SKUProdutoVendidoEstoqueSugestão de corteAções
#{row.productId}

{row.name}

{formatNumber(row.quantitySold)} un.{formatNumber(row.stockQuantity)} un.{formatNumber(row.suggestedQuantity)} un. +
+ + +
+
+
+ { setItemsPerPage(size); setCurrentPage(1); }} + /> +
+ ) : ( +
+ +

Nenhum SKU sem composição neste filtro.

+
+ )} +
+ )} {editingProduct && ( {

Gestão de materiais

Cadastros e controles que sustentam o planejamento operacional.

-
+
+
@@ -1677,7 +1678,10 @@ const MovementsTab = ({ }; const InventoryScreen = () => { - const [activeTab, setActiveTab] = useState('dashboard'); + const [searchParams, setSearchParams] = useSearchParams(); + const requestedTab = searchParams.get('tab'); + const activeTab = inventoryTabs.some(tab => tab.id === requestedTab) ? requestedTab as InventoryTab : 'dashboard'; + const setActiveTab = (tab: InventoryTab) => setSearchParams(tab === 'dashboard' ? {} : { tab }); const [summary, setSummary] = useState(emptySupplySummary); const [stockSnapshot, setStockSnapshot] = useState([]); const [isLoading, setIsLoading] = useState(true);