Improve cutting correction workflow
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m46s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m46s
This commit is contained in:
@@ -11,6 +11,8 @@ import type { CuttingSettings, DateRange, ProductAnalyticsItem, ProductionOrderI
|
||||
type CutFilter = 'need' | 'all' | 'issues' | 'covered';
|
||||
type CutSort = 'need_desc' | 'need_asc' | 'demand_desc' | 'stock_asc' | 'sold_desc' | 'name_asc';
|
||||
type SaveStatus = 'idle' | 'saving' | 'saved' | 'error';
|
||||
type CutProductIssue = Exclude<CutIssue, 'missing_yield_rule'>;
|
||||
type CorrectionIssueFilter = CutProductIssue | 'all';
|
||||
|
||||
const SETTINGS_STORAGE_KEY = 'nexstar_cutting_settings';
|
||||
const coverageTargetOptions = [7, 15, 30, 60];
|
||||
@@ -41,6 +43,13 @@ const issueHelp: Record<CutIssue, string> = {
|
||||
missing_yield_rule: 'Família reconhecida, mas ainda falta cadastrar unidades por rolo.'
|
||||
};
|
||||
|
||||
const correctionFilterOptions: Array<{ value: CorrectionIssueFilter; label: string }> = [
|
||||
{ value: 'all', label: 'Todas pendências' },
|
||||
{ value: 'missing_family_rule', label: issueLabels.missing_family_rule },
|
||||
{ value: 'missing_color', label: issueLabels.missing_color },
|
||||
{ value: 'missing_size', label: issueLabels.missing_size }
|
||||
];
|
||||
|
||||
const familyStyles: Record<CutFamilyKey, string> = {
|
||||
BLCS: 'border-emerald-400/30 bg-emerald-400/10 text-emerald-300',
|
||||
BLOS: 'border-sky-400/30 bg-sky-400/10 text-sky-300',
|
||||
@@ -117,6 +126,8 @@ const Cutting = () => {
|
||||
const [cuttingSettings, setCuttingSettings] = useState<CuttingSettings>(loadCuttingSettings);
|
||||
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle');
|
||||
const [hasUnsavedSettings, setHasUnsavedSettings] = useState(false);
|
||||
const [correctionIssueFilter, setCorrectionIssueFilter] = useState<CorrectionIssueFilter>('all');
|
||||
const [correctionPage, setCorrectionPage] = useState(1);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(10);
|
||||
|
||||
@@ -228,12 +239,31 @@ 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 correctionRows = useMemo(() => {
|
||||
const rows = cutPlan.rows.filter(row => (
|
||||
row.issues.some(issue => issue !== 'missing_yield_rule') &&
|
||||
(correctionIssueFilter === 'all' || row.issues.includes(correctionIssueFilter))
|
||||
));
|
||||
|
||||
return [...rows].sort((a, b) => {
|
||||
if (b.suggestedCutQuantity !== a.suggestedCutQuantity) {
|
||||
return b.suggestedCutQuantity - a.suggestedCutQuantity;
|
||||
}
|
||||
return a.name.localeCompare(b.name, 'pt-BR');
|
||||
});
|
||||
}, [correctionIssueFilter, cutPlan.rows]);
|
||||
const correctionItemsPerPage = 12;
|
||||
const correctionTotalPages = Math.ceil(correctionRows.length / correctionItemsPerPage);
|
||||
const safeCorrectionPage = Math.min(correctionPage, correctionTotalPages || 1);
|
||||
const correctionStartIndex = (safeCorrectionPage - 1) * correctionItemsPerPage;
|
||||
const paginatedCorrectionRows = correctionRows.slice(correctionStartIndex, correctionStartIndex + correctionItemsPerPage);
|
||||
const configuredYieldCount = CUT_FAMILY_RULES.filter(rule => cuttingSettings.familyYields[rule.key]).length;
|
||||
const productOverrideCount = Object.keys(cuttingSettings.productOverrides).length;
|
||||
|
||||
useEffect(() => {
|
||||
setCorrectionPage(1);
|
||||
}, [correctionIssueFilter, dateRange, targetCoverageDays]);
|
||||
|
||||
const updateFamilyYield = (familyKey: CutFamilyKey, value: string) => {
|
||||
const parsedValue = Number(value);
|
||||
setCuttingSettings(current => {
|
||||
@@ -436,68 +466,124 @@ const Cutting = () => {
|
||||
))}
|
||||
</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 className="rounded-xl border border-dark-border">
|
||||
<div className="flex flex-col gap-3 border-b border-dark-border bg-dark-header p-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<h3 className="text-xs font-bold uppercase tracking-widest text-dark-muted">Correções de produto</h3>
|
||||
<p className="mt-1 text-xs font-semibold text-dark-muted">
|
||||
Use quando o nome do SKU não deixa claro família, cor ou tamanho para montar o corte.
|
||||
</p>
|
||||
</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 className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<span className="text-xs font-bold text-dark-muted">
|
||||
{formatNumber(correctionRows.length)} produtos
|
||||
</span>
|
||||
<select
|
||||
value={correctionIssueFilter}
|
||||
onChange={(event) => {
|
||||
setCorrectionIssueFilter(event.target.value as CorrectionIssueFilter);
|
||||
setCorrectionPage(1);
|
||||
}}
|
||||
className="h-9 rounded-lg border border-dark-border bg-dark-input px-3 text-xs font-bold text-dark-text outline-none focus:border-brand-primary cursor-pointer"
|
||||
>
|
||||
{correctionFilterOptions.map(option => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{correctionRows.length ? (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<div className="min-w-[760px]">
|
||||
<div className="grid grid-cols-[120px_1.4fr_150px_150px_120px_80px] gap-3 border-b border-dark-border 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">
|
||||
{paginatedCorrectionRows.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 flex flex-wrap gap-1.5">
|
||||
{row.issues.filter(issue => issue !== 'missing_yield_rule').map(issue => (
|
||||
<span key={issue} className="rounded-full border border-amber-400/25 bg-amber-400/10 px-2 py-0.5 text-[10px] font-bold text-amber-300">
|
||||
{issueLabels[issue]}
|
||||
</span>
|
||||
))}
|
||||
</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>
|
||||
|
||||
<PaginationControls
|
||||
totalItems={correctionRows.length}
|
||||
currentPage={safeCorrectionPage}
|
||||
totalPages={correctionTotalPages}
|
||||
pageSize={correctionItemsPerPage}
|
||||
pageSizeOptions={[12]}
|
||||
itemLabel="produtos"
|
||||
pageSizeLabel="por página"
|
||||
startIndex={correctionStartIndex}
|
||||
endIndex={Math.min(correctionStartIndex + correctionItemsPerPage, correctionRows.length)}
|
||||
onPageChange={setCorrectionPage}
|
||||
onPageSizeChange={() => setCorrectionPage(1)}
|
||||
className="border-t border-dark-border px-4 py-3"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="px-4 py-8 text-center">
|
||||
<p className="text-sm font-bold text-dark-text">Nenhuma correção de produto pendente.</p>
|
||||
<p className="mt-1 text-xs font-semibold text-dark-muted">As pendências restantes, se existirem, são de rendimento por família.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -579,7 +665,12 @@ const Cutting = () => {
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setCutFilter('issues');
|
||||
setIsSettingsOpen(true);
|
||||
if (summary.issue !== 'missing_yield_rule') {
|
||||
setCorrectionIssueFilter(summary.issue);
|
||||
}
|
||||
setCurrentPage(1);
|
||||
setCorrectionPage(1);
|
||||
}}
|
||||
className="rounded-xl border border-dark-border bg-dark-card p-3 text-left transition-colors hover:border-amber-400/50 cursor-pointer"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user