import { type FormEvent, useEffect, useMemo, useState } from 'react'; import { Link as RouterLink, Navigate, useOutletContext, useParams } from 'react-router-dom'; import { AlertTriangle, ArrowLeft, ArrowRight, BarChart3, Boxes, ClipboardCheck, ClipboardList, Download, Link as LinkIcon, Package, PackageSearch, Pencil, RefreshCw, Repeat2, Ruler, Save, Search, Scissors, Truck, Trash2, Warehouse, } from 'lucide-react'; import { buildConsumptionReferencePath } from '../catalogLinks'; import DateRangePicker from '../components/DateRangePicker'; import PaginationControls from '../components/PaginationControls'; 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'; const pageClassName = 'mx-auto flex w-full max-w-7xl flex-col gap-6'; const panelClassName = 'rounded-2xl border border-dark-border bg-dark-card shadow-sm'; const buttonClassName = 'inline-flex h-10 items-center justify-center gap-2 rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary cursor-pointer'; const inputClassName = 'h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary'; const emptyStateClassName = 'flex min-h-[190px] flex-col items-center justify-center gap-2 px-4 py-10 text-center'; const emptySupplySummary: SupplySummary = { receipts: [], lots: [], movements: [], fabricPlans: [], purchaseNeeds: [], stats: { totalQuantityKg: 0, activeLots: 0, rolls: 0, alerts: 0, pendingReceipts: 0, approvedReceipts: 0, }, }; const formatNumber = (value: number, maximumFractionDigits = 2) => ( new Intl.NumberFormat('pt-BR', { maximumFractionDigits }).format(value) ); const parseDecimal = (value: string) => { const number = Number(value.replace(',', '.')); return Number.isFinite(number) ? number : 0; }; const formatDateTime = (value: string | null) => { if (!value) return '-'; return new Intl.DateTimeFormat('pt-BR', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit', }).format(new Date(value)); }; const normalizeSearch = (value: string) => value.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase(); const exportCsv = (filename: string, rows: Array>) => { if (!rows.length) return; const headers = Object.keys(rows[0]); const escapeCell = (value: string | number | null) => `"${String(value ?? '').replace(/"/g, '""')}"`; const csv = [ headers.join(','), ...rows.map(row => headers.map(header => escapeCell(row[header])).join(',')), ].join('\n'); const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); const url = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = url; anchor.download = filename; anchor.click(); URL.revokeObjectURL(url); }; const inventoryTabs: Array<{ id: InventoryTab; name: string; icon: typeof BarChart3 }> = [ { id: 'dashboard', name: 'Dashboard', icon: Warehouse }, { id: 'balance', name: 'Saldo', icon: BarChart3 }, { id: 'receipts', name: 'Recebimentos', icon: Truck }, { id: 'inventory', name: 'Inventário', icon: ClipboardCheck }, { id: 'movements', name: 'Movimentações', icon: Repeat2 }, ]; const receiptCategories: Array<{ name: string; icon: typeof Package }> = [ { name: 'Malha / Tecido', icon: Ruler }, { name: 'Embalagem', icon: Package }, { name: 'Material de Limpeza', icon: Boxes }, { name: 'Acessórios / Botões', icon: Warehouse }, { name: 'Etiquetas', icon: LinkIcon }, { 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 demanda ativa e cobertura abaixo da meta.', inputs: 'Insumos, embalagens e matérias-primas com necessidade de reposição.', review: 'SKUs com informações pendentes para qualificar o planejamento.', dead: 'Itens com saldo disponível e baixa movimentação 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 && ( Suprimentos )}

{title}

{subtitle}

); const ModuleCard = ({ title, description, icon: Icon, to, }: { title: string; description: string; icon: typeof Package; to: string; }) => { return (

{title}

{description}

); }; 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 => ( ))}
SKU Produto Tipo Média/dia Estoque Cobertura {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` }, { label: 'Lotes ativos', value: `${summary.stats.activeLots}` }, { label: 'Rolos', value: `${formatNumber(summary.stats.rolls, 0)}` }, { label: 'Alertas', value: `${summary.stats.alerts}` }, ]; return (
{stats.map(stat => (

{stat.value}

{stat.label}

))}
); }; const InventoryDashboard = ({ summary, onNewReceipt }: { summary: SupplySummary; onNewReceipt: () => void }) => { const categories = summary.lots.reduce>((acc, lot) => { const current = acc[lot.category] || { quantity: 0, unit: lot.unit, lots: 0 }; acc[lot.category] = { quantity: current.quantity + lot.quantity, unit: current.unit === lot.unit ? lot.unit : 'mix', lots: current.lots + 1, }; return acc; }, {}); const latestReceipts = summary.receipts.slice(0, 4); return (

Por categoria de material

{Object.keys(categories).length ? (
{Object.entries(categories).map(([category, data]) => (

{category}

{formatNumber(data.quantity)} {data.unit}

{data.lots} lote(s) ativo(s)

))}
) : (

Nenhuma categoria com saldo

Registre entradas para agrupar o estoque por tipo de material.

)}

Alertas de estoque

Nenhum alerta no momento.

Últimas entradas

{latestReceipts.length ? (
{latestReceipts.map(receipt => (

{receipt.product}

{receipt.category} · {formatDateTime(receipt.createdAt)}

{formatNumber(receipt.quantity)} {receipt.unit}

{receipt.status === 'approved' ? 'Aprovado' : 'Pendente'}
))}
) : (

Nenhuma entrada registrada.

)}
); }; const BalanceTab = ({ summary, onRefresh }: { summary: SupplySummary; onRefresh: () => void }) => { const [search, setSearch] = useState(''); const [category, setCategory] = useState('all'); const [supplier, setSupplier] = useState('all'); const categories = Array.from(new Set(summary.lots.map(lot => lot.category))).sort(); const suppliers = Array.from(new Set(summary.lots.map(lot => lot.supplier || 'Sem fornecedor'))).sort(); const normalizedSearch = normalizeSearch(search); const visibleLots = summary.lots.filter(lot => { const lotSupplier = lot.supplier || 'Sem fornecedor'; const matchesSearch = !normalizedSearch || normalizeSearch(`${lot.product} ${lot.category} ${lotSupplier} ${lot.invoice} ${lot.id}`).includes(normalizedSearch); const matchesCategory = category === 'all' || lot.category === category; const matchesSupplier = supplier === 'all' || lotSupplier === supplier; return matchesSearch && matchesCategory && matchesSupplier; }); return (

Saldo por tipo

Clique para expandir lotes
{visibleLots.length ? (
Material Categoria Saldo Fornecedor Lote
{visibleLots.map(lot => (

{lot.product}

{lot.category}

{formatNumber(lot.quantity)} {lot.unit}

{lot.supplier || 'Sem fornecedor'}

#{lot.id} · {lot.invoice || 'sem NF'}

))}
) : (

{summary.lots.length ? 'Nenhum lote encontrado' : 'Nenhum item em estoque'}

{summary.lots.length ? 'Ajuste a busca ou os filtros.' : 'Registre entradas para ver o saldo aqui.'}

)}
); }; const ReceiptList = ({ receipts, emptyTitle, onApprove, onRemove, }: { receipts: SupplyReceipt[]; emptyTitle: string; onApprove: (receiptId: number) => void; onRemove: (receiptId: number) => void; }) => (
{receipts.length ? (
{receipts.map(receipt => (

{receipt.product}

{receipt.category} · {receipt.invoice ? `NF ${receipt.invoice}` : 'sem NF'} · {formatDateTime(receipt.createdAt)}

{formatNumber(receipt.quantity)} {receipt.unit}

{receipt.supplier}

{receipt.status === 'approved' ? 'Aprovado' : 'Pendente'}
{receipt.status === 'pending' && ( )}
))}
) : (

{emptyTitle}

Registre um recebimento para preencher esta lista.

)}
); const ReceiptsTab = ({ receipts, onCreate, onApprove, onRemove, isBusy, }: { receipts: SupplyReceipt[]; onCreate: (payload: { category: string; product: string; quantity: number; unit: string; supplier: string; invoice: string; notes: string }) => Promise; onApprove: (receiptId: number) => Promise; onRemove: (receiptId: number) => Promise; isBusy: boolean; }) => { const [activeView, setActiveView] = useState('new'); const [selectedCategory, setSelectedCategory] = useState(receiptCategories[0].name); const [form, setForm] = useState({ product: '', quantity: '', unit: 'kg', supplier: '', invoice: '', notes: '', }); const pendingReceipts = receipts.filter(receipt => receipt.status === 'pending'); const visibleReceipts = activeView === 'pending' ? pendingReceipts : receipts; const handleReceiptSubmit = async (event: FormEvent) => { event.preventDefault(); const product = form.product.trim(); const quantity = parseDecimal(form.quantity); if (!product || quantity <= 0) return; await onCreate({ category: selectedCategory, product, quantity, unit: form.unit, supplier: form.supplier.trim() || 'Sem fornecedor', invoice: form.invoice.trim(), notes: form.notes.trim(), }); setForm({ product: '', quantity: '', unit: 'kg', supplier: '', invoice: '', notes: '' }); setActiveView('pending'); }; return (
{[ { id: 'new', label: 'Novo recebimento' }, { id: 'pending', label: `Pendentes (${pendingReceipts.length})` }, { id: 'history', label: `Histórico (${receipts.length})` }, ].map(item => ( ))}
{activeView === 'new' && (

Registrar recebimento

Preencha o que chegou. O financeiro vincula OC/NF e aprova o lançamento.

1. Qual categoria de produto chegou?

{receiptCategories.map(category => ( ))}

2. Detalhes do recebimento