Centralize planning data exceptions
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m43s

This commit is contained in:
Cauê Faleiros
2026-08-04 15:09:57 -03:00
parent 859107d945
commit 3f8e5eeb0c
2 changed files with 295 additions and 8 deletions

View File

@@ -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<ProductAnalyticsItem[]>([]);
const [settings, setSettings] = useState<CuttingSettings>({ familyYields: {}, productOverrides: {} });
const [supplySummary, setSupplySummary] = useState<SupplySummary>(emptySupplySummary);
const [stockSnapshot, setStockSnapshot] = useState<StockData[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [activeQueue, setActiveQueue] = useState<ExceptionQueue>('negative_stock');
const [searchTerm, setSearchTerm] = useState('');
const [issueFilter, setIssueFilter] = useState<IssueFilter>('all');
const [editingProduct, setEditingProduct] = useState<PlanningIssueRow | null>(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<string, MissingCompositionRow>();
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 = () => {
<div>
<h1 className="mb-2 text-2xl font-bold text-zinc-900 dark:text-dark-text">Dados Pendentes</h1>
<p className="font-medium text-zinc-500 dark:text-dark-muted">
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.
</p>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center 2xl:justify-end">
@@ -241,6 +327,65 @@ const PlanningIssues = () => {
<RefreshStatus isRefreshing={isRefreshing} />
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
{[
{
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 (
<button
key={queue.id}
type="button"
onClick={() => {
setActiveQueue(queue.id);
setCurrentPage(1);
setSearchTerm('');
}}
className={`rounded-2xl border p-5 text-left transition-colors cursor-pointer ${tone}`}
>
<div className="flex items-start justify-between gap-3">
<span className="text-xs font-bold uppercase tracking-widest text-dark-muted">{queue.label}</span>
<Icon className={`h-4 w-4 ${iconTone}`} />
</div>
<div className="mt-3 text-3xl font-bold text-dark-text">{formatNumber(queue.count)}</div>
<p className="mt-1 text-xs font-semibold text-dark-muted">{queue.description}</p>
</button>
);
})}
</div>
{activeQueue === 'cutting_data' ? (
<>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3 xl:grid-cols-5">
{issueFilters.filter(option => option.value !== 'all').map(option => {
const count = issueCounts.get(option.value as IssueKey) || 0;
@@ -422,6 +567,144 @@ const PlanningIssues = () => {
)}
</div>
)}
</>
) : (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-3 rounded-2xl border border-dark-border bg-dark-card p-4 shadow-sm xl:grid-cols-[minmax(360px,1fr)_auto] xl:items-center">
<div className="relative w-full md:w-96">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-dark-muted" />
<input
type="text"
value={searchTerm}
onChange={(event) => {
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"
/>
</div>
<p className="text-sm font-semibold text-dark-muted">
{formatNumber(activeRowCount)} {activeQueue === 'negative_stock' ? 'materiais para conferir' : 'SKUs fora do cálculo'}
</p>
</div>
{isLoading && !products.length ? (
<PlanningIssuesSkeleton />
) : activeQueue === 'negative_stock' ? (
visibleNegativeStockRows.length ? (
<div className="overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-sm">
<div className="border-b border-dark-border px-5 py-4">
<h2 className="text-base font-bold text-dark-text">Saldos negativos</h2>
<p className="mt-1 text-sm font-semibold text-dark-muted">São exibidos para conferência, mas contam como zero disponível no planejamento.</p>
</div>
<div className="overflow-x-auto">
<table className="w-full min-w-[860px] text-left text-sm">
<thead className="border-b border-dark-border bg-dark-header text-dark-muted">
<tr>
<th className="px-5 py-4 text-[10px] font-bold uppercase tracking-wider">SKU</th>
<th className="px-5 py-4 text-[10px] font-bold uppercase tracking-wider">Material</th>
<th className="px-5 py-4 text-right text-[10px] font-bold uppercase tracking-wider">Saldo informado</th>
<th className="px-5 py-4 text-[10px] font-bold uppercase tracking-wider">Tratamento no plano</th>
<th className="px-5 py-4 text-right text-[10px] font-bold uppercase tracking-wider">Ação</th>
</tr>
</thead>
<tbody className="divide-y divide-dark-border">
{paginatedNegativeStockRows.map(row => (
<tr key={row.produto_id} className="transition-colors hover:bg-dark-input/50">
<td className="px-5 py-3 font-mono text-xs font-semibold text-dark-muted">#{row.produto_id}</td>
<td className="max-w-[420px] px-5 py-3"><p className="truncate font-bold text-dark-text" title={row.nome}>{row.nome}</p></td>
<td className="px-5 py-3 text-right font-bold text-red-300">{formatNumber(Number(row.saldo), 2)} un.</td>
<td className="px-5 py-3"><span className="inline-flex rounded-full border border-dark-border bg-dark-input px-2.5 py-1 text-xs font-bold text-dark-muted">0 disponível</span></td>
<td className="px-5 py-3 text-right">
<Link to="/supplies/inventory?tab=stock" className="inline-flex h-8 items-center gap-1.5 rounded-lg bg-dark-input px-2.5 text-xs font-bold text-dark-text transition-colors hover:bg-dark-border cursor-pointer">
Conferir <Eye className="h-3.5 w-3.5" />
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
<PaginationControls
totalItems={visibleNegativeStockRows.length}
currentPage={safeCurrentPage}
totalPages={totalPages}
pageSize={itemsPerPage}
pageSizeOptions={[10, 20, 50, 100]}
itemLabel="materiais"
pageSizeLabel="por página"
startIndex={startIndex}
endIndex={Math.min(startIndex + itemsPerPage, visibleNegativeStockRows.length)}
onPageChange={setCurrentPage}
onPageSizeChange={(size) => { setItemsPerPage(size); setCurrentPage(1); }}
/>
</div>
) : (
<div className="flex flex-col items-center justify-center rounded-2xl border border-dark-border bg-dark-card px-6 py-16 text-center shadow-sm">
<CheckCircle2 className="h-10 w-10 text-emerald-300" />
<p className="mt-4 text-sm font-bold text-dark-text">Nenhum saldo negativo neste filtro.</p>
</div>
)
) : visibleMissingCompositionRows.length ? (
<div className="overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-sm">
<div className="border-b border-dark-border px-5 py-4">
<h2 className="text-base font-bold text-dark-text">SKUs sem composição</h2>
<p className="mt-1 text-sm font-semibold text-dark-muted">Estes produtos continuam operando, mas não geram demanda de material até receberem composição.</p>
</div>
<div className="overflow-x-auto">
<table className="w-full min-w-[940px] text-left text-sm">
<thead className="border-b border-dark-border bg-dark-header text-dark-muted">
<tr>
<th className="px-5 py-4 text-[10px] font-bold uppercase tracking-wider">SKU</th>
<th className="px-5 py-4 text-[10px] font-bold uppercase tracking-wider">Produto</th>
<th className="px-5 py-4 text-right text-[10px] font-bold uppercase tracking-wider">Vendido</th>
<th className="px-5 py-4 text-right text-[10px] font-bold uppercase tracking-wider">Estoque</th>
<th className="px-5 py-4 text-right text-[10px] font-bold uppercase tracking-wider">Sugestão de corte</th>
<th className="px-5 py-4 text-right text-[10px] font-bold uppercase tracking-wider">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-dark-border">
{paginatedMissingCompositionRows.map(row => (
<tr key={row.productId} className="transition-colors hover:bg-dark-input/50">
<td className="px-5 py-3 font-mono text-xs font-semibold text-dark-muted">#{row.productId}</td>
<td className="max-w-[420px] px-5 py-3"><p className="truncate font-bold text-dark-text" title={row.name}>{row.name}</p></td>
<td className="px-5 py-3 text-right font-bold text-dark-text">{formatNumber(row.quantitySold)} un.</td>
<td className="px-5 py-3 text-right font-bold text-dark-text">{formatNumber(row.stockQuantity)} un.</td>
<td className="px-5 py-3 text-right font-bold text-amber-300">{formatNumber(row.suggestedQuantity)} un.</td>
<td className="px-5 py-3 text-right">
<div className="flex justify-end gap-2">
<Link to={buildConsumptionReferencePath({ sku: row.productId, name: row.name })} className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-dark-input text-dark-text transition-colors hover:bg-dark-border cursor-pointer" title="Cadastrar composição" aria-label={`Cadastrar composição de ${row.name}`}><Pencil className="h-3.5 w-3.5" /></Link>
<Link to={`/products/${row.productId}`} className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-brand-primary/10 text-brand-primary transition-opacity hover:opacity-80 cursor-pointer" title="Ver produto" aria-label={`Ver produto ${row.name}`}><Eye className="h-3.5 w-3.5" /></Link>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<PaginationControls
totalItems={visibleMissingCompositionRows.length}
currentPage={safeCurrentPage}
totalPages={totalPages}
pageSize={itemsPerPage}
pageSizeOptions={[10, 20, 50, 100]}
itemLabel="SKUs"
pageSizeLabel="por página"
startIndex={startIndex}
endIndex={Math.min(startIndex + itemsPerPage, visibleMissingCompositionRows.length)}
onPageChange={setCurrentPage}
onPageSizeChange={(size) => { setItemsPerPage(size); setCurrentPage(1); }}
/>
</div>
) : (
<div className="flex flex-col items-center justify-center rounded-2xl border border-dark-border bg-dark-card px-6 py-16 text-center shadow-sm">
<CheckCircle2 className="h-10 w-10 text-emerald-300" />
<p className="mt-4 text-sm font-bold text-dark-text">Nenhum SKU sem composição neste filtro.</p>
</div>
)}
</div>
)}
{editingProduct && (
<SkuPlanningModal

View File

@@ -405,10 +405,11 @@ const SuppliesHub = () => {
<h2 className="text-base font-bold text-dark-text">Gestão de materiais</h2>
<p className="mt-1 text-sm font-semibold text-dark-muted">Cadastros e controles que sustentam o planejamento operacional.</p>
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 xl:grid-cols-4">
<ModuleCard title="Estoque e recebimentos" description="Saldo, lotes, recebimentos, inventário e movimentações de materiais." icon={Package} to="/supplies/inventory" meta={`${formatNumber(summary.stats.pendingReceipts, 0)} recebimentos pendentes · ${formatNumber(summary.stats.activeLots, 0)} lotes ativos`} />
<ModuleCard title="Planejamento de malha" description="Organize a fila de matéria-prima para abastecer compras e corte." icon={Ruler} to="/supplies/fabric-planning" />
<ModuleCard title="Prioridades de estoque" description="Analise reposição, cobertura e itens que precisam de revisão de cadastro." icon={PackageSearch} to="/supplies/current-planning" />
<ModuleCard title="Dados para revisar" description="Consulte exceções de estoque, composição e cadastro fora do cálculo atual." icon={ClipboardList} to="/planning-issues" meta="Abrir pendências" />
</div>
</section>
</div>
@@ -1677,7 +1678,10 @@ const MovementsTab = ({
};
const InventoryScreen = () => {
const [activeTab, setActiveTab] = useState<InventoryTab>('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<SupplySummary>(emptySupplySummary);
const [stockSnapshot, setStockSnapshot] = useState<StockData[]>([]);
const [isLoading, setIsLoading] = useState(true);