Add editable cutting rules

This commit is contained in:
Cauê Faleiros
2026-07-07 15:20:57 -03:00
parent d1220fdd3f
commit d512f8b00d
3 changed files with 255 additions and 10 deletions

View File

@@ -1,16 +1,21 @@
import { useEffect, useMemo, useState } from 'react';
import { Link, useOutletContext } from 'react-router-dom';
import { AlertTriangle, ClipboardList, Download, Layers3, Package, Palette, Ruler, Scissors, Search } from 'lucide-react';
import { AlertTriangle, ClipboardList, Download, Layers3, Package, Palette, RotateCcw, Ruler, Scissors, Search, Settings2 } from 'lucide-react';
import DateRangePicker from '../components/DateRangePicker';
import PaginationControls from '../components/PaginationControls';
import RefreshStatus from '../components/RefreshStatus';
import { CUT_FAMILY_RULES, buildCutPlan, buildOpenProductionByProductId, type CutFamilyKey, type CutIssue, type CutPlanSkuRow } from '../analytics/cutting';
import { CUT_FAMILY_RULES, buildCutPlan, buildOpenProductionByProductId, type CutFamilyKey, type CutIssue, type CutPlanSkuRow, type CutProductOverride } from '../analytics/cutting';
import { exportToCSV, fetchProductAnalytics, fetchProductionOrders } from '../dataService';
import type { DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types';
type CutFilter = 'need' | 'all' | 'issues' | 'covered';
type CutSort = 'need_desc' | 'need_asc' | 'demand_desc' | 'stock_asc' | 'sold_desc' | 'name_asc';
type CuttingSettings = {
familyYields: Partial<Record<CutFamilyKey, number>>;
productOverrides: Record<string, CutProductOverride>;
};
const SETTINGS_STORAGE_KEY = 'nexstar_cutting_settings';
const coverageTargetOptions = [7, 15, 30, 60];
const familyOptions: Array<{ value: CutFamilyKey | 'all'; label: string }> = [
{ value: 'all', label: 'Todas famílias' },
@@ -62,6 +67,20 @@ const allProductionOrdersRange = {
end: new Date(2100, 11, 31)
};
const loadCuttingSettings = (): CuttingSettings => {
try {
const rawSettings = localStorage.getItem(SETTINGS_STORAGE_KEY);
if (!rawSettings) return { familyYields: {}, productOverrides: {} };
const parsed = JSON.parse(rawSettings) as Partial<CuttingSettings>;
return {
familyYields: parsed.familyYields || {},
productOverrides: parsed.productOverrides || {}
};
} catch {
return { familyYields: {}, productOverrides: {} };
}
};
const CuttingSkeleton = () => (
<div className="overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-sm" aria-label="Carregando plano de corte">
<div className="border-b border-dark-border p-4">
@@ -92,9 +111,15 @@ const Cutting = () => {
const [familyFilter, setFamilyFilter] = useState<CutFamilyKey | 'all'>('all');
const [cutFilter, setCutFilter] = useState<CutFilter>('need');
const [sortBy, setSortBy] = useState<CutSort>('need_desc');
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [cuttingSettings, setCuttingSettings] = useState<CuttingSettings>(loadCuttingSettings);
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(10);
useEffect(() => {
localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(cuttingSettings));
}, [cuttingSettings]);
useEffect(() => {
let isMounted = true;
@@ -124,8 +149,8 @@ const Cutting = () => {
);
const cutPlan = useMemo(
() => buildCutPlan(products, dateRange, targetCoverageDays, openProductionByProductId),
[dateRange, openProductionByProductId, products, targetCoverageDays]
() => buildCutPlan(products, dateRange, targetCoverageDays, openProductionByProductId, cuttingSettings),
[cuttingSettings, dateRange, openProductionByProductId, products, targetCoverageDays]
);
const issueSummaries = useMemo(() => {
@@ -177,6 +202,51 @@ const Cutting = () => {
const startIndex = (safeCurrentPage - 1) * itemsPerPage;
const paginatedRows = filteredRows.slice(startIndex, startIndex + itemsPerPage);
const isRefreshing = isLoading && products.length > 0;
const correctionRows = cutPlan.rows.filter(row => (
row.issues.some(issue => issue !== 'missing_yield_rule')
)).slice(0, 12);
const configuredYieldCount = CUT_FAMILY_RULES.filter(rule => cuttingSettings.familyYields[rule.key]).length;
const productOverrideCount = Object.keys(cuttingSettings.productOverrides).length;
const updateFamilyYield = (familyKey: CutFamilyKey, value: string) => {
const parsedValue = Number(value);
setCuttingSettings(current => {
const familyYields = { ...current.familyYields };
if (Number.isFinite(parsedValue) && parsedValue > 0) {
familyYields[familyKey] = parsedValue;
} else {
delete familyYields[familyKey];
}
return { ...current, familyYields };
});
};
const updateProductOverride = (productId: string, patch: CutProductOverride) => {
setCuttingSettings(current => {
const currentOverride = current.productOverrides[productId] || {};
const nextOverride = { ...currentOverride, ...patch };
const normalizedOverride: CutProductOverride = {
familyKey: nextOverride.familyKey || '',
color: nextOverride.color || '',
size: nextOverride.size || ''
};
const productOverrides = { ...current.productOverrides };
if (!normalizedOverride.familyKey && !normalizedOverride.color && !normalizedOverride.size) {
delete productOverrides[productId];
} else {
productOverrides[productId] = normalizedOverride;
}
return { ...current, productOverrides };
});
};
const clearProductOverride = (productId: string) => {
setCuttingSettings(current => {
const productOverrides = { ...current.productOverrides };
delete productOverrides[productId];
return { ...current, productOverrides };
});
};
const exportRows = () => {
exportToCSV(filteredRows.map(row => ({
@@ -241,11 +311,125 @@ const Cutting = () => {
<Download size={16} className="text-brand-primary" />
<span className="hidden sm:inline">Exportar</span>
</button>
<button
type="button"
onClick={() => setIsSettingsOpen(current => !current)}
className="flex items-center justify-center gap-2 rounded-xl border border-dark-border bg-dark-card px-4 py-2.5 text-sm font-medium text-dark-text shadow-sm transition-colors hover:border-brand-primary cursor-pointer"
title="Configurar regras de corte"
>
<Settings2 size={16} className="text-brand-primary" />
<span className="hidden sm:inline">Regras</span>
</button>
</div>
</div>
<RefreshStatus isRefreshing={isRefreshing} />
{isSettingsOpen && (
<div className="space-y-4 rounded-2xl border border-dark-border bg-dark-card p-4 shadow-sm">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<h2 className="text-sm font-bold text-dark-text">Regras de Corte</h2>
<p className="mt-1 text-xs font-semibold text-dark-muted">
{configuredYieldCount} rendimentos configurados · {productOverrideCount} correções de produto
</p>
</div>
<button
type="button"
onClick={() => setCuttingSettings({ familyYields: {}, productOverrides: {} })}
className="inline-flex items-center justify-center gap-2 rounded-xl border border-dark-border bg-dark-input px-3 py-2 text-xs font-bold text-dark-muted transition-colors hover:border-red-400/40 hover:text-red-300 cursor-pointer"
>
<RotateCcw className="h-4 w-4" />
Limpar regras
</button>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-4">
{CUT_FAMILY_RULES.map(rule => (
<label key={rule.key} className="rounded-xl border border-dark-border bg-dark-input p-3">
<span className="mb-2 flex items-center justify-between gap-2">
<span className="text-xs font-bold text-dark-text">{rule.materialLabel}</span>
<span className="text-[11px] font-semibold text-dark-muted">{rule.label}</span>
</span>
<input
type="number"
min="1"
step="1"
value={cuttingSettings.familyYields[rule.key] || ''}
onChange={(event) => updateFamilyYield(rule.key, event.target.value)}
placeholder="un./rolo"
className="h-10 w-full rounded-lg border border-dark-border bg-dark-card px-3 text-sm font-bold text-dark-text outline-none transition-colors focus:border-brand-primary"
/>
</label>
))}
</div>
{!!correctionRows.length && (
<div className="overflow-x-auto rounded-xl border border-dark-border">
<div className="grid grid-cols-[120px_1.4fr_150px_150px_120px_80px] gap-3 border-b border-dark-border bg-dark-header px-4 py-3 text-[10px] font-bold uppercase tracking-wider text-dark-muted">
<span>ID</span>
<span>Produto</span>
<span>Família</span>
<span>Cor</span>
<span>Tamanho</span>
<span className="text-right">Ações</span>
</div>
<div className="divide-y divide-dark-border">
{correctionRows.map(row => {
const override = cuttingSettings.productOverrides[row.id] || {};
return (
<div key={row.id} className="grid grid-cols-[120px_1.4fr_150px_150px_120px_80px] items-center gap-3 px-4 py-3">
<span className="font-mono text-[11px] text-dark-muted">#{row.id}</span>
<div className="min-w-0">
<div className="truncate text-xs font-bold text-dark-text" title={row.name}>{row.name}</div>
<div className="mt-1 text-[10px] font-semibold text-amber-300">
{row.issues.map(issue => issueLabels[issue]).join(', ')}
</div>
</div>
<select
value={override.familyKey || ''}
onChange={(event) => updateProductOverride(row.id, { familyKey: event.target.value as CutFamilyKey | '' })}
className="h-9 rounded-lg border border-dark-border bg-dark-input px-2 text-xs font-bold text-dark-text outline-none focus:border-brand-primary cursor-pointer"
>
<option value="">Auto</option>
{familyOptions.filter(option => option.value !== 'all').map(option => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
<input
type="text"
value={override.color || ''}
onChange={(event) => updateProductOverride(row.id, { color: event.target.value })}
placeholder={row.color || 'Cor'}
className="h-9 rounded-lg border border-dark-border bg-dark-input px-2 text-xs font-bold text-dark-text outline-none focus:border-brand-primary"
/>
<input
type="text"
value={override.size || ''}
onChange={(event) => updateProductOverride(row.id, { size: event.target.value })}
placeholder={row.size || 'Tam.'}
className="h-9 rounded-lg border border-dark-border bg-dark-input px-2 text-xs font-bold text-dark-text outline-none focus:border-brand-primary"
/>
<div className="flex justify-end">
<button
type="button"
onClick={() => clearProductOverride(row.id)}
className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-dark-border bg-dark-input text-dark-muted transition-colors hover:border-brand-primary hover:text-brand-primary cursor-pointer"
title="Limpar correção"
>
<RotateCcw className="h-4 w-4" />
</button>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
)}
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
<div className="flex items-start justify-between gap-4">