Clarify supply execution states
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m47s

This commit is contained in:
Cauê Faleiros
2026-08-03 12:57:43 -03:00
parent 0ff267be2c
commit f69efb5358
3 changed files with 85 additions and 32 deletions

View File

@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { Link, useOutletContext, useSearchParams } from 'react-router-dom';
import { AlertTriangle, ArrowLeft, ClipboardList, Download, Eye, Layers3, Palette, Pencil, RotateCcw, Ruler, Save as SaveIcon, Scissors, Search, Settings2, X } from 'lucide-react';
import { AlertTriangle, ArrowLeft, ClipboardList, Download, Eye, Layers3, Pencil, RotateCcw, Ruler, Save as SaveIcon, Scissors, Search, Settings2, X } from 'lucide-react';
import DateRangePicker from '../components/DateRangePicker';
import PaginationControls from '../components/PaginationControls';
import ProductColorBadge from '../components/ProductColorBadge';
@@ -327,6 +327,24 @@ const Cutting = () => {
filteredRows.filter(row => row.suggestedCutQuantity > 0 && materialReadinessByProductId.get(row.id)?.status === 'ready')
), [filteredRows, materialReadinessByProductId]);
const cutExecutionSummary = useMemo(() => {
const rowsWithNeed = cutPlan.needRows.filter(row => row.suggestedCutQuantity > 0);
const readyRows = rowsWithNeed.filter(row => materialReadinessByProductId.get(row.id)?.status === 'ready');
const blockedByMaterial = rowsWithNeed.filter(row => {
const status = materialReadinessByProductId.get(row.id)?.status;
return status === 'blocked' || status === 'missing';
});
const blockedByData = rowsWithNeed.filter(row => materialReadinessByProductId.get(row.id)?.status === 'material_ready');
return {
rawUnits: rowsWithNeed.reduce((total, row) => total + row.suggestedCutQuantity, 0),
readyUnits: readyRows.reduce((total, row) => total + row.suggestedCutQuantity, 0),
readyRows: readyRows.length,
blockedRows: blockedByMaterial.length + blockedByData.length,
blockedByMaterial: blockedByMaterial.length,
blockedByData: blockedByData.length,
};
}, [cutPlan.needRows, materialReadinessByProductId]);
const orderPreviewTotalUnits = useMemo(() => (
rowsAvailableForOrder.reduce((total, row) => total + row.suggestedCutQuantity, 0)
), [rowsAvailableForOrder]);
@@ -1007,8 +1025,8 @@ const Cutting = () => {
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Unidades a cortar</p>
<p className="mt-2 text-3xl font-bold text-red-300">{formatNumber(cutPlan.summary.suggestedCutQuantity)}</p>
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Necessidade bruta</p>
<p className="mt-2 text-3xl font-bold text-red-300">{formatNumber(cutExecutionSummary.rawUnits)}</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">{formatNumber(cutPlan.summary.skuCount)} SKUs com necessidade</p>
</div>
<div className="rounded-xl border border-red-400/25 bg-red-400/10 p-3 text-red-300">
@@ -1020,11 +1038,9 @@ const Cutting = () => {
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Famílias</p>
<p className="mt-2 text-3xl font-bold text-dark-text">{formatNumber(cutPlan.summary.familiesWithNeed)}</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">
{cutPlan.summary.estimatedRolls === null ? 'Rolos pendentes de rendimento' : `${formatNumber(cutPlan.summary.estimatedRolls)} rolos estimados`}
</p>
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Pronto para corte</p>
<p className="mt-2 text-3xl font-bold text-emerald-300">{formatNumber(cutExecutionSummary.readyUnits)}</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">{formatNumber(cutExecutionSummary.readyRows)} SKUs liberados para gerar OP</p>
</div>
<div className="rounded-xl border border-brand-primary/25 bg-brand-primary/10 p-3 text-brand-primary">
<Layers3 className="h-5 w-5" />
@@ -1035,14 +1051,12 @@ const Cutting = () => {
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Cores / tamanhos</p>
<p className="mt-2 text-3xl font-bold text-sky-300">
{formatNumber(cutPlan.summary.colorsWithNeed)} / {formatNumber(cutPlan.summary.sizesWithNeed)}
</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">Com corte sugerido</p>
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Bloqueios</p>
<p className="mt-2 text-3xl font-bold text-amber-300">{formatNumber(cutExecutionSummary.blockedRows)}</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">{formatNumber(cutExecutionSummary.blockedByMaterial)} material · {formatNumber(cutExecutionSummary.blockedByData)} dados</p>
</div>
<div className="rounded-xl border border-sky-400/25 bg-sky-400/10 p-3 text-sky-300">
<Palette className="h-5 w-5" />
<div className="rounded-xl border border-amber-400/25 bg-amber-400/10 p-3 text-amber-300">
<AlertTriangle className="h-5 w-5" />
</div>
</div>
</div>
@@ -1050,17 +1064,27 @@ const Cutting = () => {
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">OP aberta</p>
<p className="mt-2 text-3xl font-bold text-amber-300">{formatNumber(cutPlan.summary.openProductionQuantity)}</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">Unidades abatidas quando match</p>
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Famílias</p>
<p className="mt-2 text-3xl font-bold text-brand-primary">{formatNumber(cutPlan.summary.familiesWithNeed)}</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">{cutPlan.summary.estimatedRolls === null ? 'Rendimento ainda não configurado' : `${formatNumber(cutPlan.summary.estimatedRolls)} rolos estimados`}</p>
</div>
<div className="rounded-xl border border-amber-400/25 bg-amber-400/10 p-3 text-amber-300">
<ClipboardList className="h-5 w-5" />
<div className="rounded-xl border border-brand-primary/25 bg-brand-primary/10 p-3 text-brand-primary">
<Layers3 className="h-5 w-5" />
</div>
</div>
</div>
</div>
{cutExecutionSummary.rawUnits > 0 && cutExecutionSummary.readyUnits === 0 && (
<div className="flex flex-col gap-3 rounded-2xl border border-amber-400/30 bg-amber-400/10 p-4 md:flex-row md:items-center md:justify-between">
<div>
<p className="text-sm font-bold text-amber-300"> demanda, mas nenhum SKU está liberado para corte.</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">Corrija materiais bloqueados e rendimentos antes de gerar ordens de produção.</p>
</div>
<button type="button" onClick={() => setActiveCuttingView('issues')} className="h-9 rounded-lg border border-amber-400/30 bg-dark-card px-3 text-xs font-bold text-amber-300 hover:border-amber-400 cursor-pointer">Ver pendências</button>
</div>
)}
<div className="rounded-2xl border border-dark-border bg-dark-card p-2 shadow-sm">
<div className="grid grid-cols-1 gap-2 md:grid-cols-3">
<button
@@ -1072,11 +1096,11 @@ const Cutting = () => {
<span className="flex items-center gap-3">
<Scissors className="h-4 w-4" />
<span>
<span className="block text-sm font-bold">Plano</span>
<span className="block text-xs font-semibold opacity-80">SKUs para cortar</span>
<span className="block text-sm font-bold">Necessidades</span>
<span className="block text-xs font-semibold opacity-80">Demanda por SKU</span>
</span>
</span>
<span className="text-sm font-bold">{formatNumber(rowsAvailableForOrder.length)}</span>
<span className="text-sm font-bold">{formatNumber(cutPlan.summary.skuCount)}</span>
</button>
<button

View File

@@ -201,6 +201,7 @@ const ProductionOrders = () => {
const progressCount = summary.counts.in_progress || 0;
const finishedCount = summary.counts.finished || 0;
const totalQuantity = summary.orders.reduce((total, order) => total + order.quantity, 0);
const activeOrders = summary.orders.filter(order => order.status !== 'finished' && order.status !== 'canceled');
const handleManualRefresh = () => {
void loadProductionOrders({ force: true });
@@ -352,6 +353,7 @@ const ProductionOrders = () => {
</div>
</div>
{activeOrders.length > 0 && (
<form onSubmit={handleProductionExit} className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
<div className="flex flex-col gap-2 lg:flex-row lg:items-start lg:justify-between">
<div>
@@ -373,7 +375,7 @@ const ProductionOrders = () => {
className="mt-1 h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none focus:border-brand-primary"
>
<option value="">Selecione...</option>
{summary.orders.filter(order => order.status !== 'finished' && order.status !== 'canceled').map(order => (
{activeOrders.map(order => (
<option key={order.id} value={order.id}>
{order.number || `#${order.id}`} · {order.productDescription}
</option>
@@ -429,6 +431,7 @@ const ProductionOrders = () => {
</div>
)}
</form>
)}
<div className="overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-sm">
<div className="border-b border-dark-border p-5">
@@ -655,6 +658,13 @@ const ProductionOrders = () => {
<p className="mt-1 max-w-md text-sm font-medium text-dark-muted">
Gere ordens pelo Plano de Corte para acompanhar status, produto, quantidade e baixa de material dentro do Graphs.
</p>
<Link
to="/cutting"
className="mt-5 inline-flex h-10 items-center gap-2 rounded-lg border border-brand-primary/35 bg-brand-primary/10 px-4 text-sm font-bold text-brand-primary transition-colors hover:bg-brand-primary/20"
>
<ClipboardList className="h-4 w-4" />
Ir para Plano de Corte
</Link>
</div>
)}

View File

@@ -1838,6 +1838,14 @@ const getNeedPendingLabel = (need: SupplyPurchaseNeed) => {
return /^Cadastrar rendimento:/i.test(need.material) ? 'Rendimento' : 'Referência';
};
const hasNegativeMaterialBalance = (need: SupplyPurchaseNeed) => need.stockKg < 0;
const getNeedStatusLabel = (need: SupplyPurchaseNeed) => {
if (hasNegativeMaterialBalance(need)) return 'Saldo negativo';
if (need.missingReference) return 'Sem referência';
return purchaseStatusLabels[need.status];
};
type PurchaseNeedTab = 'materials' | 'services' | 'mapping';
const sumNeedProducts = (
@@ -1887,7 +1895,13 @@ const PurchaseNeedsScreen = () => {
const materialNeeds = summary.purchaseNeeds.filter(need => !need.missingReference && !isServicePurchaseNeed(need));
const serviceNeeds = summary.purchaseNeeds.filter(need => !need.missingReference && isServicePurchaseNeed(need));
const mappingNeeds = summary.purchaseNeeds.filter(need => need.missingReference);
const activeNeeds = activeTab === 'materials' ? materialNeeds : activeTab === 'services' ? serviceNeeds : mappingNeeds;
const activeNeeds = activeTab === 'materials'
? [...materialNeeds].sort((a, b) => {
if (hasNegativeMaterialBalance(a) !== hasNegativeMaterialBalance(b)) return hasNegativeMaterialBalance(a) ? -1 : 1;
if (a.status !== b.status) return a.status === 'critical' ? -1 : b.status === 'critical' ? 1 : 0;
return b.purchaseKg - a.purchaseKg;
})
: activeTab === 'services' ? serviceNeeds : mappingNeeds;
const normalizedSearch = normalizeSearch(search);
const visibleNeeds = activeNeeds.filter(need => (
!normalizedSearch || normalizeSearch(`${need.material} ${getNeedDisplayName(need)} ${need.suppliers.join(' ')} ${need.colors.join(' ')} ${(need.products || []).map(product => `${product.productId} ${product.name}`).join(' ')}`).includes(normalizedSearch)
@@ -1908,7 +1922,7 @@ const PurchaseNeedsScreen = () => {
? 'Necessidades de beneficiamento e serviços. Não entram no total de compra de materiais.'
: 'SKUs sem consumo confiável. Corrija o cadastro antes de usar estes valores para comprar.';
const stats = [
{ label: 'Materiais a comprar', value: `${purchaseItemCount}` },
{ label: 'Itens para comprar', value: `${purchaseItemCount} materiais` },
{ label: 'Compra sugerida', value: suggestedPurchaseSummary },
{ label: 'Serviços a revisar', value: `${serviceNeeds.filter(need => need.purchaseKg > 0).length}` },
{ label: 'SKUs sem mapeamento', value: `${impactedSkuCount || mappingNeeds.length}` },
@@ -1969,9 +1983,9 @@ const PurchaseNeedsScreen = () => {
</label>
<div className="mt-4 inline-flex rounded-xl border border-dark-border bg-dark-input p-1">
{[
{ id: 'materials' as const, label: `Materiais (${materialNeeds.length})` },
{ id: 'materials' as const, label: `Materiais (${materialNeeds.length} linhas)` },
{ id: 'services' as const, label: `Serviços (${serviceNeeds.length})` },
{ id: 'mapping' as const, label: `Mapear dados (${mappingNeeds.length})` },
{ id: 'mapping' as const, label: `Mapear dados (${impactedSkuCount || mappingNeeds.length} SKUs)` },
].map(tab => (
<button key={tab.id} type="button" onClick={() => { setActiveTab(tab.id); setSearch(''); setCurrentPage(1); }} className={`rounded-lg px-3 py-2 text-xs font-bold transition-colors cursor-pointer ${activeTab === tab.id ? 'bg-brand-primary text-brand-contrast' : 'text-dark-muted hover:text-dark-text'}`}>
{tab.label}
@@ -2070,7 +2084,7 @@ const PurchaseNeedsScreen = () => {
<th scope="col" className="px-4 py-3 text-right">{activeTab === 'services' ? 'Agendado' : 'Estoque atual'}</th>
<th scope="col" className="px-4 py-3 text-right">{activeTab === 'services' ? 'Em aberto' : 'Pendente'}</th>
<th scope="col" className="px-4 py-3 text-right">{activeTab === 'services' ? 'Contratar' : 'Comprar'}</th>
<th scope="col" className="px-4 py-3 text-left">{activeTab === 'services' ? 'Ação' : 'Cobertura'}</th>
<th scope="col" className="px-4 py-3 text-left">Situação</th>
</tr>
</thead>
<tbody className="divide-y divide-dark-border bg-dark-card">
@@ -2083,7 +2097,10 @@ const PurchaseNeedsScreen = () => {
<div>
<p className="text-sm font-bold text-dark-text">{need.products.length} {need.products.length === 1 ? 'SKU impactado' : 'SKUs impactados'}</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">Demanda: {formatNumber(sumNeedProducts(need, 'suggestedQuantity'))} un.</p>
<p className="mt-1 truncate font-mono text-[10px] text-dark-muted" title={need.products.map(product => product.productId).join(', ')}>{need.products.slice(0, 3).map(product => product.productId).join(', ')}{need.products.length > 3 ? ` +${need.products.length - 3}` : ''}</p>
<p className="mt-1 flex flex-wrap gap-x-1.5 gap-y-1 font-mono text-[10px] text-dark-muted">
{need.products.slice(0, 3).map(product => <RouterLink key={product.productId} to={`/products/${product.productId}`} className="hover:text-brand-primary hover:underline">#{product.productId}</RouterLink>)}
{need.products.length > 3 && <span>+{need.products.length - 3}</span>}
</p>
</div>
) : <span className="text-xs font-semibold text-dark-muted">Plano manual</span>}
</td>
@@ -2110,13 +2127,15 @@ const PurchaseNeedsScreen = () => {
</div>
</td>
<td className="px-4 py-4 text-right align-middle text-sm font-bold text-dark-text">{formatNeedQuantity(need, need.plannedKg)}</td>
<td className="px-4 py-4 text-right align-middle text-sm font-bold text-dark-text">{activeTab === 'services' ? '—' : formatNeedQuantity(need, need.stockKg)}</td>
<td className={`px-4 py-4 text-right align-middle text-sm font-bold ${hasNegativeMaterialBalance(need) ? 'text-red-300' : 'text-dark-text'}`}>{activeTab === 'services' ? '—' : formatNeedQuantity(need, need.stockKg)}</td>
<td className="px-4 py-4 text-right align-middle text-sm font-bold text-dark-text">{activeTab === 'services' ? '—' : formatNeedQuantity(need, need.pendingKg)}</td>
<td className={`px-4 py-4 text-right align-middle text-sm font-bold ${need.purchaseKg > 0 ? 'text-amber-300' : 'text-emerald-300'}`}>{formatNeedQuantity(need, need.purchaseKg)}</td>
<td className="px-4 py-4 align-middle">
<span className={`inline-flex whitespace-nowrap rounded-full border px-2.5 py-1 text-xs font-bold ${
activeTab === 'services'
? 'border-sky-400/30 bg-sky-400/10 text-sky-300'
: hasNegativeMaterialBalance(need)
? 'border-red-400/30 bg-red-400/10 text-red-300'
: need.missingReference
? 'border-red-400/30 bg-red-400/10 text-red-300'
: need.status === 'critical'
@@ -2125,7 +2144,7 @@ const PurchaseNeedsScreen = () => {
? 'border-amber-400/30 bg-amber-400/10 text-amber-300'
: 'border-emerald-400/30 bg-emerald-400/10 text-emerald-300'
}`}>
{activeTab === 'services' ? 'Programar serviço' : need.missingReference ? 'Sem referência' : purchaseStatusLabels[need.status]}
{activeTab === 'services' ? 'Programar serviço' : getNeedStatusLabel(need)}
</span>
</td>
</tr>