Refine material planning workflows
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m0s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m0s
This commit is contained in:
@@ -18,7 +18,7 @@ type CutProductIssue = Exclude<CutIssue, 'missing_yield_rule'>;
|
||||
type CorrectionIssueFilter = CutProductIssue | 'all';
|
||||
type SettingsSection = 'rules' | 'corrections';
|
||||
type CuttingView = 'plan' | 'families' | 'issues';
|
||||
type MaterialReadiness = { status: 'ready' | 'blocked' | 'missing'; blockers: string[] };
|
||||
type MaterialReadiness = { status: 'ready' | 'material_ready' | 'blocked' | 'missing'; blockers: string[] };
|
||||
|
||||
const SETTINGS_STORAGE_KEY = 'nexstar_cutting_settings';
|
||||
const coverageTargetOptions = [7, 15, 30, 60];
|
||||
@@ -75,6 +75,8 @@ const normalizeMaterialKey = (value: string) => value
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
|
||||
const isServiceComponent = (value: string) => /\b(?:SERVI[CÇ]O|FRETE|TINTURARIA|TECELAGEM|COSTURA|ESTAMPARIA|LAVANDERIA)\b/i.test(value);
|
||||
|
||||
const formatDateKey = (date: Date) => {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
@@ -232,23 +234,47 @@ const Cutting = () => {
|
||||
.filter(Boolean)
|
||||
.forEach(key => compositionsById.set(key, composition));
|
||||
});
|
||||
const stockIndexByKey = new Map<string, number>();
|
||||
materialStock.forEach((stock, index) => {
|
||||
[stock.produto_id, stock.nome].map(normalizeMaterialKey).filter(Boolean).forEach(key => stockIndexByKey.set(key, index));
|
||||
});
|
||||
const remainingStock = materialStock.map(stock => getPlanningStock(stock.saldo));
|
||||
const readinessByProductId = new Map<string, MaterialReadiness>();
|
||||
cutPlan.rows.forEach(row => {
|
||||
// Shared stock is allocated to the largest current requirement first. This
|
||||
// prevents every SKU from claiming the same material balance independently.
|
||||
[...cutPlan.rows].sort((a, b) => b.suggestedCutQuantity - a.suggestedCutQuantity || a.name.localeCompare(b.name, 'pt-BR')).forEach(row => {
|
||||
const composition = compositionsById.get(normalizeMaterialKey(row.id));
|
||||
if (!composition?.components.length) {
|
||||
readinessByProductId.set(row.id, { status: 'missing', blockers: [] });
|
||||
return;
|
||||
}
|
||||
const blockers = composition.components.flatMap(component => {
|
||||
const stockKeys = new Set([component.componentTinyId, component.componentSku, component.productId || '', component.componentName].map(normalizeMaterialKey).filter(Boolean));
|
||||
const match = materialStock.find(stock => stockKeys.has(normalizeMaterialKey(stock.produto_id)) || stockKeys.has(normalizeMaterialKey(stock.nome)));
|
||||
const available = match ? getPlanningStock(match.saldo) : null;
|
||||
const componentPlans = composition.components
|
||||
.filter(component => !isServiceComponent(component.componentName))
|
||||
.map(component => {
|
||||
const stockKeys = [component.componentTinyId, component.componentSku, component.productId || '', component.componentName].map(normalizeMaterialKey).filter(Boolean);
|
||||
const stockIndex = stockKeys.map(key => stockIndexByKey.get(key)).find(index => index !== undefined);
|
||||
const available = stockIndex === undefined ? null : remainingStock[stockIndex];
|
||||
const required = row.suggestedCutQuantity * component.quantityPerUnit;
|
||||
return { component, stockIndex, available, required };
|
||||
});
|
||||
if (!componentPlans.length) {
|
||||
readinessByProductId.set(row.id, { status: 'missing', blockers: ['Sem material produtivo na composição'] });
|
||||
return;
|
||||
}
|
||||
const blockers = componentPlans.flatMap(({ component, available, required }) => {
|
||||
if (!Number.isFinite(component.quantityPerUnit) || component.quantityPerUnit <= 0) return [`${component.componentName} (quantidade inválida)`];
|
||||
return available === null || available < required
|
||||
? [`${component.componentName}${available === null ? ' (sem estoque vinculado)' : ` (${formatNumber(available)} / ${formatNumber(required)} ${component.unit || ''})`}`]
|
||||
: [];
|
||||
});
|
||||
readinessByProductId.set(row.id, { status: blockers.length ? 'blocked' : 'ready', blockers });
|
||||
if (blockers.length) {
|
||||
readinessByProductId.set(row.id, { status: 'blocked', blockers });
|
||||
return;
|
||||
}
|
||||
componentPlans.forEach(({ stockIndex, required }) => {
|
||||
if (stockIndex !== undefined) remainingStock[stockIndex] -= required;
|
||||
});
|
||||
readinessByProductId.set(row.id, { status: row.issues.length === 0 ? 'ready' : 'material_ready', blockers: [] });
|
||||
});
|
||||
return readinessByProductId;
|
||||
}, [compositions, cutPlan.rows, materialStock]);
|
||||
@@ -558,8 +584,9 @@ const Cutting = () => {
|
||||
const renderMaterialReadiness = (row: CutPlanSkuRow) => {
|
||||
const readiness = materialReadinessByProductId.get(row.id);
|
||||
if (readiness?.status === 'ready') return <span className="text-xs font-bold text-emerald-300">Pode cortar</span>;
|
||||
if (readiness?.status === 'material_ready') return <span className="text-xs font-bold text-sky-300">Material OK · revisar dados</span>;
|
||||
if (readiness?.status === 'missing') return <span className="text-xs font-bold text-amber-300">Sem composição</span>;
|
||||
return <span className="inline-flex max-w-[170px] truncate rounded-full border border-red-400/30 bg-red-400/10 px-2 py-1 text-xs font-bold text-red-300" title={readiness?.blockers.join(' · ')}>Falta: {readiness?.blockers[0] || 'material'}</span>;
|
||||
return <span className="inline-flex max-w-full rounded-full border border-red-400/30 bg-red-400/10 px-2 py-1 text-xs font-bold text-red-300" title={readiness?.blockers.join(' · ')}><span className="truncate">Falta: {readiness?.blockers[0] || 'material'}</span></span>;
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -1262,7 +1289,7 @@ const Cutting = () => {
|
||||
) : (
|
||||
<div className={`overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm dark:border-dark-border dark:bg-dark-card ${isRefreshing ? 'refreshing-content' : ''}`} aria-busy={isRefreshing}>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[1320px] table-fixed text-left text-sm">
|
||||
<table className="w-full min-w-[1450px] table-fixed text-left text-sm">
|
||||
<colgroup>
|
||||
<col className="w-[105px]" />
|
||||
<col className="w-[320px]" />
|
||||
@@ -1272,9 +1299,9 @@ const Cutting = () => {
|
||||
<col className="w-[125px]" />
|
||||
<col className="w-[140px]" />
|
||||
<col className="w-[80px]" />
|
||||
<col className="w-[130px]" />
|
||||
<col className="w-[170px]" />
|
||||
<col className="w-[110px]" />
|
||||
<col className="w-[230px]" />
|
||||
<col className="w-[160px]" />
|
||||
<col className="w-[100px]" />
|
||||
</colgroup>
|
||||
<thead className="border-b border-zinc-100 bg-zinc-50 text-zinc-500 dark:border-dark-border dark:bg-dark-header dark:text-dark-muted">
|
||||
<tr>
|
||||
@@ -1335,8 +1362,8 @@ const Cutting = () => {
|
||||
<td className="px-4 py-2.5 font-bold whitespace-nowrap text-zinc-900 dark:text-dark-text">
|
||||
{row.estimatedRolls === null ? '-' : formatNumber(row.estimatedRolls)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">{renderMaterialReadiness(row)}</td>
|
||||
<td className="px-4 py-2.5">{renderIssueBadge(row)}</td>
|
||||
<td className="px-4 py-2.5 align-middle">{renderMaterialReadiness(row)}</td>
|
||||
<td className="px-4 py-2.5 align-middle">{renderIssueBadge(row)}</td>
|
||||
<td className="px-4 py-2.5 text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Link
|
||||
|
||||
Reference in New Issue
Block a user