Clarify supply execution states
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m47s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m47s
This commit is contained in:
@@ -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 há 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">Há 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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user