diff --git a/src/pages/Supplies.tsx b/src/pages/Supplies.tsx index d898ca8..0cc38ca 100644 --- a/src/pages/Supplies.tsx +++ b/src/pages/Supplies.tsx @@ -1,5 +1,5 @@ -import { type FormEvent, useEffect, useState } from 'react'; -import { Link as RouterLink, Navigate, useParams } from 'react-router-dom'; +import { type FormEvent, useEffect, useMemo, useState } from 'react'; +import { Link as RouterLink, Navigate, useOutletContext, useParams } from 'react-router-dom'; import { AlertTriangle, ArrowLeft, @@ -11,6 +11,7 @@ import { Download, Link as LinkIcon, Package, + PackageSearch, Pencil, RefreshCw, Repeat2, @@ -23,9 +24,14 @@ import { Warehouse, } from 'lucide-react'; import { buildConsumptionReferencePath } from '../catalogLinks'; +import DateRangePicker from '../components/DateRangePicker'; import PaginationControls from '../components/PaginationControls'; -import { adjustSupplyLotInventory, approveSupplyReceipt, consumeSupplyLotForProduction, createSupplyFabricPlan, createSupplyReceipt, deleteSupplyFabricPlan, deleteSupplyReceipt, fetchSupplySummary } from '../dataService'; -import type { SupplyFabricPlan, SupplyLot, SupplyMovement, SupplyPurchaseNeed, SupplyReceipt, SupplySummary } from '../types'; +import ProductTypeBadge from '../components/ProductTypeBadge'; +import { classifyCutFamily } from '../analytics/cutting'; +import { adjustSupplyLotInventory, approveSupplyReceipt, consumeSupplyLotForProduction, createSupplyFabricPlan, createSupplyReceipt, deleteSupplyFabricPlan, deleteSupplyReceipt, fetchCuttingSettings, fetchProductAnalytics, fetchSupplySummary } from '../dataService'; +import { parseProductName } from '../productParsing'; +import { resolveProductType, type ProductTypeKey } from '../productClassification'; +import type { CuttingSettings, DateRange, ProductAnalyticsItem, SupplyFabricPlan, SupplyLot, SupplyMovement, SupplyPurchaseNeed, SupplyReceipt, SupplySummary } from '../types'; type InventoryTab = 'dashboard' | 'balance' | 'receipts' | 'inventory' | 'movements'; type ReceiptView = 'new' | 'pending' | 'history'; @@ -108,6 +114,38 @@ const receiptCategories: Array<{ name: string; icon: typeof Package }> = [ { name: 'Outro material', icon: ClipboardList }, ]; +type DemandQueue = 'apparel' | 'inputs' | 'review' | 'dead'; + +type DemandRow = ProductAnalyticsItem & { + productType: ProductTypeKey; + color: string; + size: string; + dailySales: number; + daysOfCover: number | null; + targetDemand: number; + suggestedUnits: number; + missingData: string[]; + queue: DemandQueue; +}; + +const demandQueueLabels: Record = { + apparel: 'Cortar / Repor', + inputs: 'Comprar insumos', + review: 'Revisar dados', + dead: 'Estoque parado', +}; + +const demandQueueDescriptions: Record = { + apparel: 'Produtos acabados com venda e cobertura baixa. Ainda é sugestão por unidade, não kg de malha.', + inputs: 'Embalagens, matérias-primas e insumos DTF com cobertura baixa pelo próprio estoque.', + review: 'SKUs vendidos que precisam de correção de tipo, cor, tamanho ou família antes de planejar melhor.', + dead: 'Itens com estoque e pouca ou nenhuma venda no período.', +}; + +const directPurchaseTypes = new Set(['packaging', 'dtf_input', 'raw_material', 'machine_part', 'finished_accessory']); + +const planningReviewTypes = new Set(['unknown', 'kit_bundle']); + const Header = ({ title, subtitle, backTo }: { title: string; subtitle: string; backTo?: string }) => (
{backTo && ( @@ -154,6 +192,7 @@ const SuppliesHub = () => (
+ @@ -163,6 +202,369 @@ const SuppliesHub = () => (
); +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 formatDays = (value: number | null) => { + if (value === null) return '-'; + if (value > 999) return '999+ dias'; + return `${formatNumber(value, value < 10 ? 1 : 0)} dias`; +}; + +const buildDemandRow = ( + product: ProductAnalyticsItem, + settings: CuttingSettings, + rangeDays: number, + targetCoverageDays: number +): DemandRow => { + const override = settings.productOverrides[product.id]; + const metadata = parseProductName(product.name); + const productType = resolveProductType(product.name, override); + const color = override?.color || metadata.color; + const size = (override?.size || metadata.size).toUpperCase(); + const dailySales = product.quantitySold / rangeDays; + const daysOfCover = dailySales > 0 ? product.stock / dailySales : null; + const targetDemand = dailySales * targetCoverageDays; + const suggestedUnits = Math.max(0, Math.ceil(targetDemand - product.stock)); + const missingData: string[] = []; + + if (planningReviewTypes.has(productType)) missingData.push('tipo'); + if (productType === 'finished_apparel') { + if (!color) missingData.push('cor'); + if (!size) missingData.push('tamanho'); + if ((override?.familyKey || classifyCutFamily(metadata.baseName).key) === 'OUTROS') missingData.push('família'); + } + + let queue: DemandQueue = 'review'; + if (product.stock > 0 && (dailySales === 0 || (daysOfCover !== null && daysOfCover > 120))) { + queue = 'dead'; + } else if (missingData.length) { + queue = 'review'; + } else if (productType === 'finished_apparel') { + queue = 'apparel'; + } else if (directPurchaseTypes.has(productType)) { + queue = 'inputs'; + } + + return { + ...product, + productType, + color, + size, + dailySales, + daysOfCover, + targetDemand, + suggestedUnits, + missingData, + queue, + }; +}; + +const DemandPlanningScreen = () => { + const { dateRange, setDateRange } = useOutletContext<{ + dateRange: DateRange, + setDateRange: (range: DateRange) => void + }>(); + const [products, setProducts] = useState([]); + const [settings, setSettings] = useState({ familyYields: {}, productOverrides: {} }); + const [isLoading, setIsLoading] = useState(true); + const [queue, setQueue] = useState('apparel'); + const [search, setSearch] = useState(''); + const [targetCoverageDays, setTargetCoverageDays] = useState(30); + const [currentPage, setCurrentPage] = useState(1); + const [itemsPerPage, setItemsPerPage] = useState(10); + + useEffect(() => { + let isMounted = true; + + const load = async () => { + setIsLoading(true); + const [productData, planningSettings] = await Promise.all([ + fetchProductAnalytics(dateRange), + fetchCuttingSettings(), + ]); + + if (isMounted) { + setProducts(productData); + setSettings(planningSettings); + setIsLoading(false); + } + }; + + void load(); + + return () => { + isMounted = false; + }; + }, [dateRange]); + + const rows = useMemo(() => { + const rangeDays = getRangeDays(dateRange); + return products.map(product => buildDemandRow(product, settings, rangeDays, targetCoverageDays)); + }, [dateRange, products, settings, targetCoverageDays]); + + const queueRows = useMemo(() => { + const normalizedSearch = normalizeSearch(search); + return rows + .filter(row => row.queue === queue) + .filter(row => { + if (queue === 'dead') return row.stock > 0; + if (queue === 'review') return row.quantitySold > 0 || row.stock > 0; + return row.dailySales > 0 && (row.suggestedUnits > 0 || (row.daysOfCover !== null && row.daysOfCover <= targetCoverageDays)); + }) + .filter(row => ( + !normalizedSearch || normalizeSearch(`${row.id} ${row.name} ${row.color} ${row.size} ${row.missingData.join(' ')}`).includes(normalizedSearch) + )) + .sort((a, b) => { + if (queue === 'dead') { + return (b.stock * b.lastPrice) - (a.stock * a.lastPrice); + } + if (queue === 'review') { + if (b.quantitySold !== a.quantitySold) return b.quantitySold - a.quantitySold; + return b.revenue - a.revenue; + } + if (b.suggestedUnits !== a.suggestedUnits) return b.suggestedUnits - a.suggestedUnits; + return b.dailySales - a.dailySales; + }); + }, [queue, rows, search, targetCoverageDays]); + + const queueStats = useMemo(() => { + return (Object.keys(demandQueueLabels) as DemandQueue[]).reduce>((acc, item) => { + acc[item] = rows.filter(row => { + if (row.queue !== item) return false; + if (item === 'dead') return row.stock > 0; + if (item === 'review') return row.quantitySold > 0 || row.stock > 0; + return row.dailySales > 0 && (row.suggestedUnits > 0 || (row.daysOfCover !== null && row.daysOfCover <= targetCoverageDays)); + }).length; + return acc; + }, { apparel: 0, inputs: 0, review: 0, dead: 0 }); + }, [rows, targetCoverageDays]); + + const totalPages = Math.ceil(queueRows.length / itemsPerPage); + const safeCurrentPage = Math.min(currentPage, totalPages || 1); + const startIndex = (safeCurrentPage - 1) * itemsPerPage; + const paginatedRows = queueRows.slice(startIndex, startIndex + itemsPerPage); + const suggestedTotal = queueRows.reduce((total, row) => total + (queue === 'dead' ? row.stock * row.lastPrice : row.suggestedUnits), 0); + + return ( +
+
+
+ { + setDateRange(range); + setCurrentPage(1); + }} + /> +
+ +
+ {(Object.keys(demandQueueLabels) as DemandQueue[]).map(item => ( + + ))} +
+ +
+
+
+

{demandQueueLabels[queue]}

+

{demandQueueDescriptions[queue]}

+
+
+ + + +
+
+ +
+
+

Itens na fila

+

{formatNumber(queueRows.length, 0)}

+
+
+

{queue === 'dead' ? 'Valor em estoque' : 'Unidades sugeridas'}

+

+ {queue === 'dead' + ? new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(suggestedTotal) + : `${formatNumber(suggestedTotal, 0)} un.`} +

+
+
+

Cobertura alvo

+

{targetCoverageDays} dias

+
+
+ + {isLoading ? ( +
+ +

Carregando dados de produtos...

+
+ ) : queueRows.length ? ( +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + {paginatedRows.map(row => ( + + + + + + + + + + + ))} + +
SKUProdutoTipoMédia/diaEstoqueCobertura{queue === 'dead' ? 'Valor estoque' : 'Sugestão'}Ações
#{row.id} +

{row.name}

+

+ Cor: {row.color || '-'} · Tam.: {row.size || '-'} + {row.missingData.length ? ` · Falta: ${row.missingData.join(', ')}` : ''} +

+
{formatNumber(row.dailySales, 2)}{formatNumber(row.stock, 0)} un.{formatDays(row.daysOfCover)} 0 ? 'text-amber-300' : 'text-emerald-300'}`}> + {queue === 'dead' + ? new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(row.stock * row.lastPrice) + : `${formatNumber(row.suggestedUnits, 0)} un.`} + +
+ {queue === 'review' && ( + + + + )} + + + +
+
+
+ { + setItemsPerPage(pageSize); + setCurrentPage(1); + }} + className="border-t border-dark-border px-4 py-3" + /> +
+ ) : ( +
+ +

Nada nesta fila

+

Ajuste a busca, período ou cobertura alvo.

+
+ )} +
+
+ ); +}; + const StatGrid = ({ summary }: { summary: SupplySummary }) => { const stats = [ { label: 'Total em estoque', value: `${formatNumber(summary.stats.totalQuantityKg)} kg` }, @@ -1364,6 +1766,7 @@ const Supplies = () => { const { section } = useParams<{ section?: string }>(); if (!section) return ; + if (section === 'current-planning') return ; if (section === 'inventory') return ; if (section === 'fabric-planning') return ; if (section === 'purchase-needs') return ;