1463 lines
78 KiB
TypeScript
1463 lines
78 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
|
import { Link, useOutletContext, useSearchParams } from 'react-router-dom';
|
|
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';
|
|
import RefreshStatus from '../components/RefreshStatus';
|
|
import { CUT_FAMILY_RULES, buildCutPlan, buildOpenProductionByProductId, type CutFamilyKey, type CutIssue, type CutPlanSkuRow, type CutProductOverride } from '../analytics/cutting';
|
|
import { createProductionOrders, exportToCSV, fetchCuttingSettings, fetchProductAnalytics, fetchProductCompositions, fetchProductionOrders, fetchStock, saveCuttingSettings } from '../dataService';
|
|
import type { CuttingSettings, DateRange, ProductAnalyticsItem, ProductComposition, ProductionOrderItem, StockData } from '../types';
|
|
import { getPlanningStock } from '../planningStock';
|
|
|
|
type CutFilter = 'need' | 'all' | 'issues' | 'covered';
|
|
type ExecutionFilter = 'all' | 'ready' | 'blocked' | 'data';
|
|
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';
|
|
type SettingsSection = 'rules' | 'corrections';
|
|
type CuttingView = 'plan' | 'families' | 'issues';
|
|
type MaterialReadiness = { status: 'ready' | 'material_ready' | 'blocked' | 'missing'; blockers: string[] };
|
|
|
|
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' },
|
|
...CUT_FAMILY_RULES.map(rule => ({ value: rule.key, label: `${rule.materialLabel} · ${rule.label}` })),
|
|
{ value: 'OUTROS', label: 'Sem regra' }
|
|
];
|
|
|
|
const filterOptions: Array<{ value: CutFilter; label: string }> = [
|
|
{ value: 'need', label: 'Com necessidade' },
|
|
{ value: 'all', label: 'Todos' },
|
|
{ value: 'issues', label: 'Dados pendentes' },
|
|
{ value: 'covered', label: 'Sem necessidade' }
|
|
];
|
|
|
|
const issueLabels: Record<CutIssue, string> = {
|
|
missing_family_rule: 'Sem família',
|
|
missing_color: 'Sem cor',
|
|
missing_size: 'Sem tamanho',
|
|
missing_yield_rule: 'Sem rendimento'
|
|
};
|
|
|
|
const issueHelp: Record<CutIssue, string> = {
|
|
missing_family_rule: 'Produto não caiu em BLCS, BLOS, BLMC ou BLPM.',
|
|
missing_color: 'Nome do produto não tem uma cor clara para montar matriz de corte.',
|
|
missing_size: 'Nome do produto não tem tamanho claro para montar matriz de corte.',
|
|
missing_yield_rule: 'Família reconhecida, mas ainda falta cadastrar unidades por rolo.'
|
|
};
|
|
|
|
const correctionFilterOptions: Array<{ value: CorrectionIssueFilter; label: string }> = [
|
|
{ value: 'all', label: 'Todos os dados pendentes' },
|
|
{ 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',
|
|
BLMC: 'border-amber-400/30 bg-amber-400/10 text-amber-300',
|
|
BLPM: 'border-purple-400/30 bg-purple-400/10 text-purple-300',
|
|
OUTROS: 'border-zinc-500/30 bg-zinc-500/10 text-zinc-300'
|
|
};
|
|
|
|
const formatNumber = (value: number, maximumFractionDigits = 0) => (
|
|
new Intl.NumberFormat('pt-BR', { maximumFractionDigits }).format(value)
|
|
);
|
|
|
|
const normalizeMaterialKey = (value: string) => value
|
|
.normalize('NFD')
|
|
.replace(/[\u0300-\u036f]/g, '')
|
|
.replace(/\s+/g, ' ')
|
|
.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');
|
|
const day = String(date.getDate()).padStart(2, '0');
|
|
return `${year}-${month}-${day}`;
|
|
};
|
|
|
|
const formatDays = (value: number | null) => {
|
|
if (value === null) return '-';
|
|
if (value > 999) return '999+ dias';
|
|
return `${formatNumber(value, value < 10 ? 1 : 0)} dias`;
|
|
};
|
|
|
|
const allProductionOrdersRange = {
|
|
start: new Date(2000, 0, 1),
|
|
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 hasCuttingSettings = (settings: CuttingSettings) => (
|
|
Object.keys(settings.familyYields).length > 0 ||
|
|
Object.keys(settings.productOverrides).length > 0
|
|
);
|
|
|
|
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">
|
|
<div className="grid grid-cols-[120px_1.4fr_130px_110px_110px_120px_130px_130px_110px] gap-6">
|
|
{[0, 1, 2, 3, 4, 5, 6, 7, 8].map(item => <div key={item} className="skeleton h-3" />)}
|
|
</div>
|
|
</div>
|
|
<div className="divide-y divide-dark-border">
|
|
{[0, 1, 2, 3, 4, 5, 6, 7].map(row => (
|
|
<div key={row} className="grid grid-cols-[120px_1.4fr_130px_110px_110px_120px_130px_130px_110px] gap-6 px-6 py-4">
|
|
{[0, 1, 2, 3, 4, 5, 6, 7, 8].map(item => <div key={item} className="skeleton h-4" />)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
const Cutting = () => {
|
|
const { dateRange, setDateRange } = useOutletContext<{
|
|
dateRange: DateRange,
|
|
setDateRange: (range: DateRange) => void
|
|
}>();
|
|
const [searchParams] = useSearchParams();
|
|
const [products, setProducts] = useState<ProductAnalyticsItem[]>([]);
|
|
const [productionOrders, setProductionOrders] = useState<ProductionOrderItem[]>([]);
|
|
const [compositions, setCompositions] = useState<ProductComposition[]>([]);
|
|
const [materialStock, setMaterialStock] = useState<StockData[]>([]);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const [targetCoverageDays, setTargetCoverageDays] = useState(30);
|
|
const [familyFilter, setFamilyFilter] = useState<CutFamilyKey | 'all'>('all');
|
|
const [cutFilter, setCutFilter] = useState<CutFilter>('need');
|
|
const [executionFilter, setExecutionFilter] = useState<ExecutionFilter>('all');
|
|
const [sortBy, setSortBy] = useState<CutSort>('need_desc');
|
|
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
|
const [settingsSection, setSettingsSection] = useState<SettingsSection>('rules');
|
|
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);
|
|
const [isGeneratingOrders, setIsGeneratingOrders] = useState(false);
|
|
const [isOrderPreviewOpen, setIsOrderPreviewOpen] = useState(false);
|
|
const [editingCutRow, setEditingCutRow] = useState<CutPlanSkuRow | null>(null);
|
|
const [quickCutEdit, setQuickCutEdit] = useState<CutProductOverride>({});
|
|
const [isQuickCutEditSaving, setIsQuickCutEditSaving] = useState(false);
|
|
const [activeCuttingView, setActiveCuttingView] = useState<CuttingView>('plan');
|
|
const [generationMessage, setGenerationMessage] = useState('');
|
|
const cutConfigSection = searchParams.get('config');
|
|
const targetCorrectionSku = (searchParams.get('sku') || '').trim();
|
|
|
|
useEffect(() => {
|
|
let isMounted = true;
|
|
|
|
const loadSettings = async () => {
|
|
const settings = await fetchCuttingSettings();
|
|
if (!isMounted) return;
|
|
|
|
if (hasCuttingSettings(settings)) {
|
|
setCuttingSettings(settings);
|
|
localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(settings));
|
|
setHasUnsavedSettings(false);
|
|
} else {
|
|
const localSettings = loadCuttingSettings();
|
|
setCuttingSettings(localSettings);
|
|
setHasUnsavedSettings(hasCuttingSettings(localSettings));
|
|
}
|
|
setSaveStatus('idle');
|
|
};
|
|
|
|
void loadSettings();
|
|
|
|
return () => {
|
|
isMounted = false;
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
let isMounted = true;
|
|
|
|
const loadProducts = async () => {
|
|
setIsLoading(true);
|
|
const [productData, productionOrderData, compositionData, stockData] = await Promise.all([
|
|
fetchProductAnalytics(dateRange),
|
|
fetchProductionOrders(allProductionOrdersRange),
|
|
fetchProductCompositions(),
|
|
fetchStock()
|
|
]);
|
|
if (isMounted) {
|
|
setProducts(productData);
|
|
setProductionOrders(productionOrderData.orders);
|
|
setCompositions(compositionData);
|
|
setMaterialStock(stockData);
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
void loadProducts();
|
|
|
|
return () => {
|
|
isMounted = false;
|
|
};
|
|
}, [dateRange]);
|
|
|
|
const openProductionByProductId = useMemo(
|
|
() => buildOpenProductionByProductId(products, productionOrders),
|
|
[products, productionOrders]
|
|
);
|
|
|
|
const cutPlan = useMemo(
|
|
() => buildCutPlan(products, dateRange, targetCoverageDays, openProductionByProductId, cuttingSettings),
|
|
[cuttingSettings, dateRange, openProductionByProductId, products, targetCoverageDays]
|
|
);
|
|
|
|
const materialReadinessByProductId = useMemo(() => {
|
|
const compositionsById = new Map<string, ProductComposition>();
|
|
compositions.forEach(composition => {
|
|
[composition.finishedTinyProductId, composition.finishedProductSku]
|
|
.map(normalizeMaterialKey)
|
|
.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>();
|
|
// 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) {
|
|
// Products without an imported composition remain eligible for cutting.
|
|
// They are excluded from material-demand calculations instead of
|
|
// blocking the rest of the production queue.
|
|
readinessByProductId.set(row.id, { status: 'ready', blockers: [] });
|
|
return;
|
|
}
|
|
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: 'ready', blockers: [] });
|
|
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 || ''})`}`]
|
|
: [];
|
|
});
|
|
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: 'ready', blockers: [] });
|
|
});
|
|
return readinessByProductId;
|
|
}, [compositions, cutPlan.rows, materialStock]);
|
|
|
|
const issueSummaries = useMemo(() => {
|
|
const counts = new Map<CutIssue, number>();
|
|
cutPlan.needRows.forEach(row => {
|
|
row.issues.forEach(issue => counts.set(issue, (counts.get(issue) || 0) + 1));
|
|
});
|
|
return Array.from(counts.entries()).map(([issue, count]) => ({ issue, count }));
|
|
}, [cutPlan.needRows]);
|
|
|
|
const filteredRows = useMemo(() => {
|
|
const normalizedSearch = searchTerm.trim().toLowerCase();
|
|
const searchedRows = normalizedSearch
|
|
? cutPlan.rows.filter(row => (
|
|
row.name.toLowerCase().includes(normalizedSearch) ||
|
|
row.id.toLowerCase().includes(normalizedSearch) ||
|
|
row.baseName.toLowerCase().includes(normalizedSearch) ||
|
|
row.color.toLowerCase().includes(normalizedSearch)
|
|
))
|
|
: cutPlan.rows;
|
|
|
|
const familyRows = familyFilter === 'all'
|
|
? searchedRows
|
|
: searchedRows.filter(row => row.family.key === familyFilter);
|
|
|
|
const statusRows = familyRows.filter(row => {
|
|
if (cutFilter === 'all') return true;
|
|
if (cutFilter === 'need') return row.suggestedCutQuantity > 0;
|
|
if (cutFilter === 'issues') return row.issues.length > 0;
|
|
return row.suggestedCutQuantity === 0;
|
|
});
|
|
|
|
const executionRows = statusRows.filter(row => {
|
|
if (executionFilter === 'all') return true;
|
|
const status = materialReadinessByProductId.get(row.id)?.status;
|
|
if (executionFilter === 'ready') return row.suggestedCutQuantity > 0 && status === 'ready';
|
|
if (executionFilter === 'blocked') return row.suggestedCutQuantity > 0 && (status === 'blocked' || status === 'missing');
|
|
return row.suggestedCutQuantity > 0 && status === 'material_ready';
|
|
});
|
|
|
|
return [...executionRows].sort((a, b) => {
|
|
switch (sortBy) {
|
|
case 'need_asc': return a.suggestedCutQuantity - b.suggestedCutQuantity;
|
|
case 'demand_desc': return b.projectedDemand - a.projectedDemand;
|
|
case 'stock_asc': return a.stock - b.stock;
|
|
case 'sold_desc': return b.quantitySold - a.quantitySold;
|
|
case 'name_asc': return a.name.localeCompare(b.name, 'pt-BR');
|
|
case 'need_desc':
|
|
default:
|
|
return b.suggestedCutQuantity - a.suggestedCutQuantity;
|
|
}
|
|
});
|
|
}, [cutFilter, cutPlan.rows, executionFilter, familyFilter, materialReadinessByProductId, searchTerm, sortBy]);
|
|
|
|
const rowsAvailableForOrder = useMemo(() => (
|
|
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]);
|
|
|
|
const totalPages = Math.ceil(filteredRows.length / itemsPerPage);
|
|
const safeCurrentPage = Math.min(currentPage, totalPages || 1);
|
|
const startIndex = (safeCurrentPage - 1) * itemsPerPage;
|
|
const paginatedRows = filteredRows.slice(startIndex, startIndex + itemsPerPage);
|
|
const isRefreshing = isLoading && products.length > 0;
|
|
const correctionRows = useMemo(() => {
|
|
const rows = cutPlan.rows.filter(row => (
|
|
row.issues.some(issue => issue !== 'missing_yield_rule') &&
|
|
(correctionIssueFilter === 'all' || row.issues.includes(correctionIssueFilter))
|
|
));
|
|
|
|
const sortedRows = [...rows].sort((a, b) => {
|
|
if (b.suggestedCutQuantity !== a.suggestedCutQuantity) {
|
|
return b.suggestedCutQuantity - a.suggestedCutQuantity;
|
|
}
|
|
return a.name.localeCompare(b.name, 'pt-BR');
|
|
});
|
|
|
|
if (!targetCorrectionSku) return sortedRows;
|
|
|
|
const targetRow = cutPlan.rows.find(row => row.id.toLowerCase() === targetCorrectionSku.toLowerCase());
|
|
if (!targetRow) return sortedRows;
|
|
|
|
return [targetRow, ...sortedRows.filter(row => row.id !== targetRow.id)];
|
|
}, [correctionIssueFilter, cutPlan.rows, targetCorrectionSku]);
|
|
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(() => {
|
|
if (cutConfigSection !== 'corrections' || !targetCorrectionSku) return;
|
|
|
|
const correctionIndex = correctionRows.findIndex(row => row.id.toLowerCase() === targetCorrectionSku.toLowerCase());
|
|
queueMicrotask(() => {
|
|
setIsSettingsOpen(true);
|
|
setSettingsSection('corrections');
|
|
setCorrectionIssueFilter('all');
|
|
if (correctionIndex >= 0) {
|
|
setCorrectionPage(Math.floor(correctionIndex / correctionItemsPerPage) + 1);
|
|
}
|
|
});
|
|
}, [correctionItemsPerPage, correctionRows, cutConfigSection, targetCorrectionSku]);
|
|
|
|
useEffect(() => {
|
|
if (!isSettingsOpen && !isOrderPreviewOpen && !editingCutRow) return undefined;
|
|
|
|
const closeOnEscape = (event: KeyboardEvent) => {
|
|
if (event.key === 'Escape') {
|
|
if (isOrderPreviewOpen) {
|
|
setIsOrderPreviewOpen(false);
|
|
} else if (editingCutRow) {
|
|
setEditingCutRow(null);
|
|
} else {
|
|
setIsSettingsOpen(false);
|
|
}
|
|
}
|
|
};
|
|
|
|
const previousOverflow = document.body.style.overflow;
|
|
document.body.style.overflow = 'hidden';
|
|
document.addEventListener('keydown', closeOnEscape);
|
|
return () => {
|
|
document.body.style.overflow = previousOverflow;
|
|
document.removeEventListener('keydown', closeOnEscape);
|
|
};
|
|
}, [editingCutRow, isOrderPreviewOpen, isSettingsOpen]);
|
|
|
|
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 };
|
|
});
|
|
setHasUnsavedSettings(true);
|
|
setSaveStatus('idle');
|
|
};
|
|
|
|
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 };
|
|
});
|
|
setHasUnsavedSettings(true);
|
|
setSaveStatus('idle');
|
|
};
|
|
|
|
const clearProductOverride = (productId: string) => {
|
|
setCuttingSettings(current => {
|
|
const productOverrides = { ...current.productOverrides };
|
|
delete productOverrides[productId];
|
|
return { ...current, productOverrides };
|
|
});
|
|
setHasUnsavedSettings(true);
|
|
setSaveStatus('idle');
|
|
};
|
|
|
|
const persistSettings = async (settings = cuttingSettings) => {
|
|
setSaveStatus('saving');
|
|
try {
|
|
const savedSettings = await saveCuttingSettings(settings);
|
|
setCuttingSettings(savedSettings);
|
|
localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(savedSettings));
|
|
setHasUnsavedSettings(false);
|
|
setSaveStatus('saved');
|
|
} catch (error) {
|
|
console.error('Save cutting settings failed', error);
|
|
setSaveStatus('error');
|
|
}
|
|
};
|
|
|
|
const clearSettings = () => {
|
|
const emptySettings = { familyYields: {}, productOverrides: {} };
|
|
setCuttingSettings(emptySettings);
|
|
setHasUnsavedSettings(true);
|
|
setSaveStatus('idle');
|
|
};
|
|
|
|
const openSettingsSection = (section: SettingsSection) => {
|
|
setSettingsSection(section);
|
|
setIsSettingsOpen(true);
|
|
};
|
|
|
|
const openQuickCutEdit = (row: CutPlanSkuRow) => {
|
|
const override = cuttingSettings.productOverrides[row.id] || {};
|
|
setEditingCutRow(row);
|
|
setQuickCutEdit({
|
|
familyKey: override.familyKey || '',
|
|
color: override.color || '',
|
|
size: override.size || ''
|
|
});
|
|
};
|
|
|
|
const saveQuickCutEdit = async () => {
|
|
if (!editingCutRow) return;
|
|
const normalizedOverride: CutProductOverride = {
|
|
familyKey: quickCutEdit.familyKey || '',
|
|
color: quickCutEdit.color || '',
|
|
size: quickCutEdit.size || ''
|
|
};
|
|
const productOverrides = { ...cuttingSettings.productOverrides };
|
|
if (!normalizedOverride.familyKey && !normalizedOverride.color && !normalizedOverride.size) {
|
|
delete productOverrides[editingCutRow.id];
|
|
} else {
|
|
productOverrides[editingCutRow.id] = normalizedOverride;
|
|
}
|
|
const nextSettings = { ...cuttingSettings, productOverrides };
|
|
setIsQuickCutEditSaving(true);
|
|
try {
|
|
const savedSettings = await saveCuttingSettings(nextSettings);
|
|
setCuttingSettings(savedSettings);
|
|
localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(savedSettings));
|
|
setHasUnsavedSettings(false);
|
|
setSaveStatus('saved');
|
|
setEditingCutRow(null);
|
|
} catch (error) {
|
|
console.error('Save quick cut edit failed', error);
|
|
setSaveStatus('error');
|
|
} finally {
|
|
setIsQuickCutEditSaving(false);
|
|
}
|
|
};
|
|
|
|
const openBulkCorrectionForRow = (row: CutPlanSkuRow) => {
|
|
const correctionIndex = correctionRows.findIndex(item => item.id === row.id);
|
|
setEditingCutRow(null);
|
|
setSettingsSection('corrections');
|
|
setCorrectionIssueFilter('all');
|
|
if (correctionIndex >= 0) setCorrectionPage(Math.floor(correctionIndex / correctionItemsPerPage) + 1);
|
|
setIsSettingsOpen(true);
|
|
};
|
|
|
|
const exportRows = () => {
|
|
exportToCSV(filteredRows.map(row => ({
|
|
'ID Produto': row.id,
|
|
'Descrição': row.name,
|
|
'Família': row.family.label,
|
|
'Material': row.family.materialLabel,
|
|
'Cor': row.color,
|
|
'Tamanho': row.size,
|
|
'Vendido no período': row.quantitySold,
|
|
'Média diária': row.dailySales.toFixed(2).replace('.', ','),
|
|
'Demanda projetada': row.projectedDemand.toFixed(2).replace('.', ','),
|
|
'Estoque': row.stock,
|
|
'OP aberta': row.openProductionQuantity,
|
|
'Disponível': row.availableQuantity,
|
|
'Necessidade corte': row.suggestedCutQuantity,
|
|
'Rendimento un/rolo': row.family.unitsPerRoll || '',
|
|
'Rolos estimados': row.estimatedRolls || '',
|
|
'Cobertura': row.daysOfCover === null ? '' : row.daysOfCover.toFixed(1).replace('.', ','),
|
|
'Pronto para cortar': materialReadinessByProductId.get(row.id)?.status === 'ready' ? 'Sim' : materialReadinessByProductId.get(row.id)?.status === 'blocked' ? 'Não - falta material' : 'Sem composição',
|
|
'Materiais bloqueando': materialReadinessByProductId.get(row.id)?.blockers.join(' | ') || '',
|
|
'Dados pendentes': row.issues.map(issue => issueLabels[issue]).join(' | ')
|
|
})), `plano_corte_${new Date().toISOString().split('T')[0]}.csv`);
|
|
};
|
|
|
|
const openProductionOrderPreview = () => {
|
|
if (!rowsAvailableForOrder.length) {
|
|
setGenerationMessage('Não há SKU com necessidade e materiais disponíveis no filtro atual.');
|
|
return;
|
|
}
|
|
|
|
setGenerationMessage('');
|
|
setIsOrderPreviewOpen(true);
|
|
};
|
|
|
|
const generateProductionOrders = async () => {
|
|
if (!rowsAvailableForOrder.length) {
|
|
setGenerationMessage('Não há SKU com necessidade e materiais disponíveis no filtro atual.');
|
|
return;
|
|
}
|
|
|
|
setIsGeneratingOrders(true);
|
|
setGenerationMessage('');
|
|
|
|
const issueDate = formatDateKey(new Date());
|
|
const orderReference = `Plano de corte ${formatDateKey(dateRange.start)} a ${formatDateKey(dateRange.end)} · ${targetCoverageDays} dias`;
|
|
|
|
try {
|
|
const result = await createProductionOrders(rowsAvailableForOrder.map(row => ({
|
|
status: 'open',
|
|
orderReference,
|
|
issueDate,
|
|
productSku: row.id,
|
|
productDescription: row.name,
|
|
quantity: row.suggestedCutQuantity,
|
|
unit: 'UN',
|
|
integrationStatus: 'Local',
|
|
markers: [
|
|
{ label: row.family.materialLabel, color: '#38bdf8' },
|
|
...(row.color ? [{ label: row.color, color: '#52DFA0' }] : []),
|
|
{ label: 'Material confirmado', color: '#52DFA0' },
|
|
...(row.issues.length ? [{ label: 'Dados pendentes', color: '#f59e0b' }] : [])
|
|
],
|
|
metadata: {
|
|
source: 'cut_plan',
|
|
familyKey: row.family.key,
|
|
familyLabel: row.family.label,
|
|
materialLabel: row.family.materialLabel,
|
|
color: row.color,
|
|
size: row.size,
|
|
targetCoverageDays,
|
|
projectedDemand: row.projectedDemand,
|
|
stock: row.stock,
|
|
openProductionQuantity: row.openProductionQuantity,
|
|
availableQuantity: row.availableQuantity,
|
|
issues: row.issues
|
|
}
|
|
})));
|
|
|
|
const refreshedOrders = await fetchProductionOrders(allProductionOrdersRange, undefined, { force: true });
|
|
setProductionOrders(refreshedOrders.orders);
|
|
setGenerationMessage(`${result.created.length} OPs criadas${result.skipped.length ? ` · ${result.skipped.length} já existiam abertas` : ''}.`);
|
|
setIsOrderPreviewOpen(false);
|
|
} catch (error) {
|
|
setGenerationMessage(error instanceof Error ? error.message : 'Não foi possível gerar as ordens de produção.');
|
|
} finally {
|
|
setIsGeneratingOrders(false);
|
|
}
|
|
};
|
|
|
|
const renderIssueBadge = (row: CutPlanSkuRow) => {
|
|
if (!row.issues.length) {
|
|
return <span className="text-xs font-bold text-emerald-300">OK</span>;
|
|
}
|
|
|
|
const issueSummary = row.issues.map(issue => issueLabels[issue]).join(', ');
|
|
|
|
return (
|
|
<span
|
|
className="inline-flex max-w-full items-center gap-1.5 rounded-full border border-amber-400/30 bg-amber-400/10 px-2.5 py-1 text-xs font-bold text-amber-300"
|
|
title={issueSummary}
|
|
>
|
|
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
|
|
<span className="truncate">
|
|
{row.issues.length === 1 ? issueLabels[row.issues[0]] : `${row.issues.length} dados`}
|
|
</span>
|
|
</span>
|
|
);
|
|
};
|
|
|
|
const renderCutStatus = (row: CutPlanSkuRow) => {
|
|
const readiness = materialReadinessByProductId.get(row.id);
|
|
if (readiness?.status === 'ready') {
|
|
return <span className="inline-flex rounded-full border border-emerald-400/30 bg-emerald-400/10 px-2.5 py-1 text-xs font-bold text-emerald-300">Pronto para cortar</span>;
|
|
}
|
|
if (readiness?.status === 'material_ready') {
|
|
return <span className="inline-flex max-w-full items-center gap-1.5 rounded-full border border-amber-400/30 bg-amber-400/10 px-2.5 py-1 text-xs font-bold text-amber-300" title={row.issues.map(issue => issueLabels[issue]).join(', ')}><AlertTriangle className="h-3.5 w-3.5 shrink-0" /><span className="truncate">Dados pendentes</span></span>;
|
|
}
|
|
if (readiness?.status === 'missing') {
|
|
return <span className="inline-flex rounded-full border border-amber-400/30 bg-amber-400/10 px-2.5 py-1 text-xs font-bold text-amber-300">Sem composição</span>;
|
|
}
|
|
return <span className="inline-flex max-w-full rounded-full border border-red-400/30 bg-red-400/10 px-2.5 py-1 text-xs font-bold text-red-300" title={readiness?.blockers.join(' · ')}><span className="truncate">Falta: {readiness?.blockers[0] || 'material'}</span></span>;
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="grid grid-cols-1 gap-4 2xl:grid-cols-[minmax(520px,1fr)_auto] 2xl:items-start">
|
|
<div>
|
|
<Link to="/supplies" className="mb-3 inline-flex items-center gap-2 text-sm font-bold text-zinc-500 transition-colors hover:text-zinc-900 dark:text-dark-muted dark:hover:text-dark-text">
|
|
<ArrowLeft className="h-4 w-4" />
|
|
Suprimentos
|
|
</Link>
|
|
<h1 className="mb-2 text-2xl font-bold text-zinc-900 dark:text-dark-text">Plano de Corte</h1>
|
|
<p className="font-medium text-zinc-500 dark:text-dark-muted">
|
|
Necessidade por família, cor e tamanho calculada com vendas, estoque e cobertura alvo.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
|
|
<DateRangePicker
|
|
dateRange={dateRange}
|
|
onChange={(range) => {
|
|
setDateRange(range);
|
|
setCurrentPage(1);
|
|
setCorrectionPage(1);
|
|
}}
|
|
/>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={openProductionOrderPreview}
|
|
disabled={isGeneratingOrders || !rowsAvailableForOrder.length}
|
|
className="flex items-center justify-center gap-2 rounded-xl border border-brand-primary/30 bg-brand-primary/15 px-4 py-2.5 text-sm font-medium text-brand-primary shadow-sm transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
|
|
title="Revisar ordens locais antes de gerar"
|
|
>
|
|
<ClipboardList size={16} />
|
|
<span className="hidden sm:inline">{isGeneratingOrders ? 'Gerando' : 'Prévia OPs'}</span>
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={exportRows}
|
|
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="Exportar para CSV"
|
|
>
|
|
<Download size={16} className="text-brand-primary" />
|
|
<span className="hidden sm:inline">Exportar</span>
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => openSettingsSection('rules')}
|
|
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 rendimentos por família"
|
|
>
|
|
<Settings2 size={16} className="text-brand-primary" />
|
|
<span className="hidden sm:inline">Regras</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{generationMessage && (
|
|
<div className="rounded-xl border border-dark-border bg-dark-card px-4 py-3 text-sm font-bold text-dark-muted">
|
|
{generationMessage}
|
|
</div>
|
|
)}
|
|
|
|
<RefreshStatus isRefreshing={isRefreshing} />
|
|
|
|
{isOrderPreviewOpen && (
|
|
<div
|
|
className="fixed inset-0 z-50 flex h-dvh items-center justify-center overflow-hidden bg-zinc-950/80 p-4 backdrop-blur-md dark:bg-black/75"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label="Prévia de ordens de produção"
|
|
onClick={() => {
|
|
if (!isGeneratingOrders) setIsOrderPreviewOpen(false);
|
|
}}
|
|
>
|
|
<div
|
|
className="flex max-h-[90dvh] w-full max-w-6xl flex-col overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-2xl"
|
|
onClick={(event) => event.stopPropagation()}
|
|
>
|
|
<div className="flex flex-col gap-4 border-b border-dark-border p-5 md:flex-row md:items-start md:justify-between">
|
|
<div>
|
|
<h2 className="text-lg font-bold text-dark-text">Prévia das OPs</h2>
|
|
<p className="mt-1 text-sm font-semibold text-dark-muted">
|
|
Serão criadas ordens locais para as necessidades do filtro atual.
|
|
</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsOrderPreviewOpen(false)}
|
|
disabled={isGeneratingOrders}
|
|
className="inline-flex h-9 w-9 items-center justify-center rounded-xl border border-dark-border bg-dark-input text-dark-muted transition-colors hover:border-brand-primary hover:text-dark-text disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
|
|
title="Fechar"
|
|
aria-label="Fechar prévia"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 gap-3 border-b border-dark-border p-4 md:grid-cols-3">
|
|
<div className="rounded-xl border border-dark-border bg-dark-input/60 p-3">
|
|
<p className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">OPs previstas</p>
|
|
<p className="mt-2 text-2xl font-bold text-dark-text">{formatNumber(rowsAvailableForOrder.length)}</p>
|
|
</div>
|
|
<div className="rounded-xl border border-dark-border bg-dark-input/60 p-3">
|
|
<p className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">Unidades totais</p>
|
|
<p className="mt-2 text-2xl font-bold text-red-300">{formatNumber(orderPreviewTotalUnits)} un.</p>
|
|
</div>
|
|
<div className="rounded-xl border border-dark-border bg-dark-input/60 p-3">
|
|
<p className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">Cobertura alvo</p>
|
|
<p className="mt-2 text-2xl font-bold text-brand-primary">{formatNumber(targetCoverageDays)} dias</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="overflow-auto">
|
|
<table className="w-full min-w-[980px] text-left text-sm">
|
|
<thead className="border-b border-dark-border bg-dark-header text-dark-muted">
|
|
<tr>
|
|
<th className="px-4 py-3 text-[10px] font-bold uppercase tracking-wider">SKU</th>
|
|
<th className="px-4 py-3 text-[10px] font-bold uppercase tracking-wider">Produto</th>
|
|
<th className="px-4 py-3 text-[10px] font-bold uppercase tracking-wider">Família</th>
|
|
<th className="px-4 py-3 text-[10px] font-bold uppercase tracking-wider">Variação</th>
|
|
<th className="px-4 py-3 text-[10px] font-bold uppercase tracking-wider">Necessidade</th>
|
|
<th className="px-4 py-3 text-[10px] font-bold uppercase tracking-wider">Dados</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-dark-border">
|
|
{rowsAvailableForOrder.map(row => (
|
|
<tr key={row.id} className="hover:bg-dark-input/50">
|
|
<td className="px-4 py-3 font-mono text-[11px] text-dark-muted">#{row.id}</td>
|
|
<td className="max-w-0 px-4 py-3">
|
|
<div className="truncate font-bold text-dark-text" title={row.name}>{row.name}</div>
|
|
<div className="mt-1 text-[10px] font-semibold text-dark-muted">
|
|
Média: {formatNumber(row.dailySales, 2)} un./dia · Estoque: {formatNumber(row.stock)} un.
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<span className={`inline-flex whitespace-nowrap rounded-full border px-2.5 py-1 text-xs font-bold ${familyStyles[row.family.key]}`}>
|
|
{row.family.materialLabel}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<div className="flex min-w-0 items-center gap-1.5">
|
|
<ProductColorBadge label={row.color} className="max-w-[110px] px-2" />
|
|
<span className="inline-flex items-center gap-1 rounded-full border border-emerald-400/25 bg-emerald-400/10 px-2 py-1 text-xs font-bold text-emerald-300">
|
|
<Ruler className="h-3 w-3" />
|
|
{row.size || '-'}
|
|
</span>
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-3 font-bold whitespace-nowrap text-red-300">
|
|
{formatNumber(row.suggestedCutQuantity)} un.
|
|
</td>
|
|
<td className="px-4 py-3">{renderIssueBadge(row)}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-3 border-t border-dark-border p-4 sm:flex-row sm:items-center sm:justify-between">
|
|
<p className="text-xs font-semibold text-dark-muted">
|
|
OPs abertas já existentes para o mesmo plano e SKU serão ignoradas.
|
|
</p>
|
|
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsOrderPreviewOpen(false)}
|
|
disabled={isGeneratingOrders}
|
|
className="inline-flex items-center justify-center rounded-xl border border-dark-border bg-dark-input px-4 py-2 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
|
|
>
|
|
Cancelar
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => void generateProductionOrders()}
|
|
disabled={isGeneratingOrders}
|
|
className="inline-flex items-center justify-center gap-2 rounded-xl border border-brand-primary/30 bg-brand-primary/15 px-4 py-2 text-sm font-bold text-brand-primary transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
|
|
>
|
|
<ClipboardList className="h-4 w-4" />
|
|
{isGeneratingOrders ? 'Gerando OPs' : 'Confirmar geração'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{editingCutRow && (
|
|
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm" role="dialog" aria-modal="true" aria-label="Editar dados de corte" onClick={() => !isQuickCutEditSaving && setEditingCutRow(null)}>
|
|
<div className="w-full max-w-md rounded-2xl border border-dark-border bg-dark-card shadow-2xl" onClick={(event) => event.stopPropagation()}>
|
|
<div className="flex items-start justify-between gap-4 border-b border-dark-border p-5">
|
|
<div className="min-w-0">
|
|
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Editar dados de corte</p>
|
|
<h2 className="mt-1 truncate text-base font-bold text-dark-text" title={editingCutRow.name}>{editingCutRow.name}</h2>
|
|
<p className="mt-1 font-mono text-xs font-semibold text-brand-primary">#{editingCutRow.id}</p>
|
|
</div>
|
|
<button type="button" onClick={() => setEditingCutRow(null)} disabled={isQuickCutEditSaving} title="Fechar" aria-label="Fechar" className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border border-dark-border bg-dark-input text-dark-muted transition-colors hover:border-brand-primary hover:text-dark-text disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"><X className="h-4 w-4" /></button>
|
|
</div>
|
|
<div className="space-y-4 p-5">
|
|
<div className="flex flex-wrap gap-2 text-xs font-bold text-dark-muted">
|
|
<span className={`rounded-full border px-2.5 py-1 ${familyStyles[editingCutRow.family.key]}`}>{editingCutRow.family.materialLabel}</span>
|
|
<ProductColorBadge label={editingCutRow.color} className="px-2.5 py-1 text-xs" />
|
|
<span className="rounded-full border border-emerald-400/25 bg-emerald-400/10 px-2.5 py-1 text-emerald-300">{editingCutRow.size || 'Sem tamanho'}</span>
|
|
</div>
|
|
<p className="text-xs font-semibold leading-relaxed text-dark-muted">Altere apenas quando a leitura automática do SKU estiver incorreta. Salvar atualiza este SKU imediatamente.</p>
|
|
<label className="block text-xs font-bold text-dark-muted">Família<select value={quickCutEdit.familyKey || ''} onChange={(event) => setQuickCutEdit(current => ({ ...current, familyKey: event.target.value as CutFamilyKey | '' }))} className="mt-1 h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-bold text-dark-text outline-none focus:border-brand-primary cursor-pointer"><option value="">Automática ({editingCutRow.family.materialLabel})</option>{familyOptions.filter(option => option.value !== 'all').map(option => <option key={option.value} value={option.value}>{option.label}</option>)}</select></label>
|
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
|
<label className="text-xs font-bold text-dark-muted">Cor<input value={quickCutEdit.color || ''} onChange={(event) => setQuickCutEdit(current => ({ ...current, color: event.target.value }))} placeholder={editingCutRow.color || 'Informar cor'} className="mt-1 h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-bold text-dark-text outline-none focus:border-brand-primary" /></label>
|
|
<label className="text-xs font-bold text-dark-muted">Tamanho<input value={quickCutEdit.size || ''} onChange={(event) => setQuickCutEdit(current => ({ ...current, size: event.target.value }))} placeholder={editingCutRow.size || 'Informar tamanho'} className="mt-1 h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-bold text-dark-text outline-none focus:border-brand-primary" /></label>
|
|
</div>
|
|
<button type="button" onClick={() => setQuickCutEdit({})} className="inline-flex items-center gap-2 text-xs font-bold text-dark-muted transition-colors hover:text-dark-text cursor-pointer"><RotateCcw className="h-3.5 w-3.5" /> Usar leitura automática</button>
|
|
</div>
|
|
<div className="flex flex-col gap-2 border-t border-dark-border p-4 sm:flex-row sm:items-center sm:justify-between">
|
|
<button type="button" onClick={() => openBulkCorrectionForRow(editingCutRow)} disabled={isQuickCutEditSaving} className="text-left text-xs font-bold text-brand-primary transition-colors hover:text-brand-primary/80 disabled:opacity-50 cursor-pointer">Abrir correções em lote</button>
|
|
<div className="flex gap-2 sm:justify-end"><button type="button" onClick={() => setEditingCutRow(null)} disabled={isQuickCutEditSaving} className="h-10 rounded-xl border border-dark-border bg-dark-input px-3 text-xs font-bold text-dark-text transition-colors hover:border-brand-primary disabled:opacity-50 cursor-pointer">Cancelar</button><button type="button" onClick={() => void saveQuickCutEdit()} disabled={isQuickCutEditSaving} className="inline-flex h-10 items-center gap-2 rounded-xl bg-brand-primary px-3 text-xs font-bold text-brand-contrast transition-colors hover:bg-brand-primary/90 disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"><SaveIcon className="h-4 w-4" />{isQuickCutEditSaving ? 'Salvando' : 'Salvar'}</button></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{isSettingsOpen && (
|
|
<div
|
|
className="fixed inset-0 z-50 flex h-dvh justify-end overflow-hidden bg-zinc-950/80 backdrop-blur-md dark:bg-black/75"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label="Regras de corte"
|
|
onClick={() => setIsSettingsOpen(false)}
|
|
>
|
|
<div
|
|
className="h-dvh w-full max-w-6xl space-y-4 overflow-y-auto border-l border-dark-border bg-dark-card p-5 shadow-2xl"
|
|
onClick={(event) => event.stopPropagation()}
|
|
>
|
|
<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">Configuração de Corte</h2>
|
|
<p className="mt-1 text-xs font-semibold text-dark-muted">
|
|
{configuredYieldCount} rendimentos configurados · {productOverrideCount} correções de produto
|
|
</p>
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={clearSettings}
|
|
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 tudo
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => void persistSettings()}
|
|
disabled={saveStatus === 'saving' || !hasUnsavedSettings}
|
|
className="inline-flex items-center justify-center gap-2 rounded-xl border border-brand-primary/30 bg-brand-primary/15 px-3 py-2 text-xs font-bold text-brand-primary transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
|
|
>
|
|
<SaveIcon className="h-4 w-4" />
|
|
{saveStatus === 'saving' ? 'Salvando' : 'Salvar'}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsSettingsOpen(false)}
|
|
className="inline-flex h-9 w-9 items-center justify-center rounded-xl border border-dark-border bg-dark-input text-dark-muted transition-colors hover:border-brand-primary hover:text-dark-text cursor-pointer"
|
|
title="Fechar"
|
|
aria-label="Fechar regras"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{saveStatus === 'saved' && (
|
|
<p className="text-xs font-semibold text-emerald-300">Regras salvas no banco de dados.</p>
|
|
)}
|
|
{saveStatus === 'error' && (
|
|
<p className="text-xs font-semibold text-red-300">Não foi possível salvar as regras. Tente novamente.</p>
|
|
)}
|
|
{hasUnsavedSettings && saveStatus !== 'saving' && (
|
|
<p className="text-xs font-semibold text-amber-300">Existem alterações ainda não salvas.</p>
|
|
)}
|
|
|
|
<div className="inline-flex rounded-xl border border-dark-border bg-dark-input p-1">
|
|
<button
|
|
type="button"
|
|
onClick={() => setSettingsSection('rules')}
|
|
aria-pressed={settingsSection === 'rules'}
|
|
className={`rounded-lg px-3 py-2 text-xs font-bold transition-colors cursor-pointer ${settingsSection === 'rules' ? 'bg-brand-primary/15 text-brand-primary' : 'text-dark-muted hover:text-dark-text'}`}
|
|
>
|
|
Rendimentos
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setSettingsSection('corrections')}
|
|
aria-pressed={settingsSection === 'corrections'}
|
|
className={`rounded-lg px-3 py-2 text-xs font-bold transition-colors cursor-pointer ${settingsSection === 'corrections' ? 'bg-brand-primary/15 text-brand-primary' : 'text-dark-muted hover:text-dark-text'}`}
|
|
>
|
|
Correções
|
|
</button>
|
|
</div>
|
|
|
|
{settingsSection === 'rules' && (
|
|
<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>
|
|
)}
|
|
|
|
{settingsSection === 'corrections' && (
|
|
<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="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] || {};
|
|
const isTargetRow = targetCorrectionSku.toLowerCase() === row.id.toLowerCase();
|
|
return (
|
|
<div
|
|
key={row.id}
|
|
className={`grid grid-cols-[120px_1.4fr_150px_150px_120px_80px] items-center gap-3 px-4 py-3 ${isTargetRow ? 'bg-brand-primary/10 ring-1 ring-inset ring-brand-primary/35' : ''}`}
|
|
>
|
|
<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">Os dados restantes, se existirem, são de rendimento por família.</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="rounded-2xl border border-dark-border bg-dark-card p-1.5 shadow-sm">
|
|
<div className="flex flex-wrap gap-1.5">
|
|
<button type="button" onClick={() => setActiveCuttingView('plan')} aria-pressed={activeCuttingView === 'plan'} className={`inline-flex h-10 items-center gap-2 rounded-xl px-3 text-sm font-bold transition-colors cursor-pointer ${activeCuttingView === 'plan' ? 'bg-brand-primary/15 text-brand-primary' : 'text-dark-muted hover:bg-dark-input hover:text-dark-text'}`}>
|
|
<Scissors className="h-4 w-4" /> Necessidades <span className="text-xs opacity-80">{formatNumber(cutPlan.summary.skuCount)}</span>
|
|
</button>
|
|
<button type="button" onClick={() => setActiveCuttingView('families')} aria-pressed={activeCuttingView === 'families'} className={`inline-flex h-10 items-center gap-2 rounded-xl px-3 text-sm font-bold transition-colors cursor-pointer ${activeCuttingView === 'families' ? 'bg-brand-primary/15 text-brand-primary' : 'text-dark-muted hover:bg-dark-input hover:text-dark-text'}`}>
|
|
<Layers3 className="h-4 w-4" /> Famílias <span className="text-xs opacity-80">{formatNumber(cutPlan.familySummaries.length)}</span>
|
|
</button>
|
|
<button type="button" onClick={() => setActiveCuttingView('issues')} aria-pressed={activeCuttingView === 'issues'} className={`inline-flex h-10 items-center gap-2 rounded-xl px-3 text-sm font-bold transition-colors cursor-pointer ${activeCuttingView === 'issues' ? 'bg-amber-400/10 text-amber-300' : 'text-dark-muted hover:bg-dark-input hover:text-dark-text'}`}>
|
|
<AlertTriangle className="h-4 w-4" /> Pendências <span className="text-xs opacity-80">{formatNumber(cutPlan.summary.rowsWithIssues)}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 divide-x divide-y divide-dark-border overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-sm lg:grid-cols-4 lg:divide-y-0">
|
|
{[
|
|
{ id: 'all', label: 'Necessidade', value: `${formatNumber(cutExecutionSummary.rawUnits)} un.`, detail: `${formatNumber(cutPlan.summary.skuCount)} SKUs`, tone: 'text-dark-text' },
|
|
{ id: 'ready', label: 'Pronto para corte', value: `${formatNumber(cutExecutionSummary.readyUnits)} un.`, detail: `${formatNumber(cutExecutionSummary.readyRows)} SKUs liberados`, tone: 'text-emerald-300' },
|
|
{ id: 'blocked', label: 'Bloqueados', value: formatNumber(cutExecutionSummary.blockedRows), detail: `${formatNumber(cutExecutionSummary.blockedByMaterial)} material · ${formatNumber(cutExecutionSummary.blockedByData)} dados`, tone: cutExecutionSummary.blockedRows ? 'text-amber-300' : 'text-dark-text' },
|
|
{ id: 'families', label: 'Famílias', value: formatNumber(cutPlan.summary.familiesWithNeed), detail: cutPlan.summary.estimatedRolls === null ? 'Rendimento a configurar' : `${formatNumber(cutPlan.summary.estimatedRolls)} rolos estimados`, tone: 'text-dark-text' }
|
|
].map(metric => (
|
|
<button
|
|
key={metric.id}
|
|
type="button"
|
|
onClick={() => {
|
|
if (metric.id === 'families') {
|
|
setActiveCuttingView('families');
|
|
return;
|
|
}
|
|
setActiveCuttingView('plan');
|
|
setExecutionFilter(metric.id as ExecutionFilter);
|
|
setCutFilter('need');
|
|
setCurrentPage(1);
|
|
}}
|
|
className={`p-4 text-left transition-colors hover:bg-dark-input/60 cursor-pointer sm:px-5 ${metric.id === 'families' ? activeCuttingView === 'families' ? 'bg-brand-primary/[0.07]' : '' : activeCuttingView === 'plan' && executionFilter === metric.id ? 'bg-brand-primary/[0.07]' : ''}`}
|
|
>
|
|
<p className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">{metric.label}</p>
|
|
<p className={`mt-1 text-xl font-bold ${metric.tone}`}>{metric.value}</p>
|
|
<p className="mt-1 truncate text-xs font-semibold text-dark-muted">{metric.detail}</p>
|
|
</button>
|
|
))}
|
|
</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>
|
|
)}
|
|
|
|
{activeCuttingView === 'issues' && (
|
|
<div className="rounded-2xl border border-dark-border bg-dark-card p-4 shadow-sm">
|
|
<div className="mb-3 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
|
<div>
|
|
<h2 className="text-sm font-bold text-dark-text">Dados pendentes do plano</h2>
|
|
<p className="mt-1 text-xs font-semibold text-dark-muted">Dados que bloqueiam o cálculo completo: família, cor, tamanho ou rendimento.</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="rounded-full border border-amber-400/30 bg-amber-400/10 px-3 py-1 text-xs font-bold text-amber-300">
|
|
{formatNumber(cutPlan.summary.rowsWithIssues)} SKUs
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setCorrectionIssueFilter('all');
|
|
setCorrectionPage(1);
|
|
openSettingsSection('corrections');
|
|
}}
|
|
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-text transition-colors hover:border-brand-primary cursor-pointer"
|
|
>
|
|
<Settings2 className="h-4 w-4 text-brand-primary" />
|
|
Corrigir
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setCutFilter('issues');
|
|
setCurrentPage(1);
|
|
setActiveCuttingView('plan');
|
|
}}
|
|
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-text transition-colors hover:border-brand-primary cursor-pointer"
|
|
>
|
|
<Search className="h-4 w-4 text-brand-primary" />
|
|
Ver SKUs
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{issueSummaries.length ? (
|
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-4">
|
|
{issueSummaries.map(summary => (
|
|
<div
|
|
key={summary.issue}
|
|
className="rounded-xl border border-dark-border bg-dark-input/60 p-3"
|
|
>
|
|
<div className="flex items-center justify-between gap-3">
|
|
<span className="text-xs font-bold text-dark-text">{issueLabels[summary.issue]}</span>
|
|
<span className="text-sm font-bold text-amber-300">{formatNumber(summary.count)}</span>
|
|
</div>
|
|
<p className="mt-1 line-clamp-2 text-[11px] font-medium leading-relaxed text-dark-muted">{issueHelp[summary.issue]}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="rounded-xl border border-dark-border bg-dark-input/60 px-4 py-8 text-center">
|
|
<p className="text-sm font-bold text-dark-text">Nenhuma pendência no plano atual.</p>
|
|
<p className="mt-1 text-xs font-semibold text-dark-muted">Os SKUs com necessidade já têm os dados mínimos para o cálculo.</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{activeCuttingView === 'families' && (
|
|
<div className="rounded-2xl border border-dark-border bg-dark-card p-4 shadow-sm">
|
|
<div className="mb-3 flex items-center justify-between gap-3">
|
|
<div>
|
|
<h2 className="text-sm font-bold text-dark-text">Corte por família</h2>
|
|
<p className="mt-1 text-xs font-semibold text-dark-muted">Volume agrupado para orientar compra e corte.</p>
|
|
</div>
|
|
<Scissors className="h-5 w-5 text-dark-muted" />
|
|
</div>
|
|
{cutPlan.familySummaries.length ? (
|
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 2xl:grid-cols-5">
|
|
{cutPlan.familySummaries.map(summary => (
|
|
<div
|
|
key={summary.family.key}
|
|
className="rounded-xl border border-dark-border bg-dark-input/50 p-3"
|
|
>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<span className={`inline-flex rounded-full border px-2.5 py-1 text-[11px] font-bold ${familyStyles[summary.family.key]}`}>
|
|
{summary.family.materialLabel}
|
|
</span>
|
|
<h3 className="mt-2 truncate text-sm font-bold text-dark-text">{summary.family.label}</h3>
|
|
</div>
|
|
<span className="text-[11px] font-bold text-dark-muted">{formatNumber(summary.skuCount)} SKUs</span>
|
|
</div>
|
|
<p className="mt-3 text-2xl font-bold text-dark-text">{formatNumber(summary.suggestedCutQuantity)} un.</p>
|
|
<p className="mt-1 text-xs font-semibold text-dark-muted">
|
|
{formatNumber(summary.colorCount)} cores · {summary.estimatedRolls === null ? 'sem rendimento' : `${formatNumber(summary.estimatedRolls)} rolos`}
|
|
</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="rounded-xl border border-dark-border bg-dark-input/60 px-4 py-8 text-center">
|
|
<p className="text-sm font-bold text-dark-text">Nenhuma família com necessidade no período.</p>
|
|
<p className="mt-1 text-xs font-semibold text-dark-muted">Ajuste o período ou a cobertura alvo para revisar outro cenário.</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{activeCuttingView === 'plan' && (
|
|
<>
|
|
<div className="grid grid-cols-1 gap-3 rounded-2xl border border-dark-border bg-dark-card p-4 shadow-sm xl:grid-cols-[1fr_170px_170px_170px_190px]">
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-zinc-400 dark:text-dark-muted" />
|
|
<input
|
|
type="text"
|
|
placeholder="Buscar por nome, ID, grupo ou cor..."
|
|
value={searchTerm}
|
|
onChange={(event) => {
|
|
setSearchTerm(event.target.value);
|
|
setCurrentPage(1);
|
|
}}
|
|
className="w-full rounded-xl border border-dark-border bg-dark-input py-2.5 pl-10 pr-4 text-dark-text transition-colors hover:border-brand-primary focus:border-brand-primary focus:outline-none"
|
|
/>
|
|
</div>
|
|
|
|
<select
|
|
value={targetCoverageDays}
|
|
onChange={(event) => {
|
|
setTargetCoverageDays(Number(event.target.value));
|
|
setCurrentPage(1);
|
|
setCorrectionPage(1);
|
|
}}
|
|
className="h-11 rounded-xl border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text focus:border-brand-primary focus:outline-none cursor-pointer"
|
|
aria-label="Dias de cobertura alvo"
|
|
>
|
|
{coverageTargetOptions.map(days => <option key={days} value={days}>Cobrir {days} dias</option>)}
|
|
</select>
|
|
|
|
<select
|
|
value={familyFilter}
|
|
onChange={(event) => {
|
|
setFamilyFilter(event.target.value as CutFamilyKey | 'all');
|
|
setCurrentPage(1);
|
|
}}
|
|
className="h-11 rounded-xl border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text focus:border-brand-primary focus:outline-none cursor-pointer"
|
|
>
|
|
{familyOptions.map(option => <option key={option.value} value={option.value}>{option.label}</option>)}
|
|
</select>
|
|
|
|
<select
|
|
value={cutFilter}
|
|
onChange={(event) => {
|
|
setCutFilter(event.target.value as CutFilter);
|
|
setCurrentPage(1);
|
|
}}
|
|
className="h-11 rounded-xl border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text focus:border-brand-primary focus:outline-none cursor-pointer"
|
|
>
|
|
{filterOptions.map(option => <option key={option.value} value={option.value}>{option.label}</option>)}
|
|
</select>
|
|
|
|
<select
|
|
value={sortBy}
|
|
onChange={(event) => {
|
|
setSortBy(event.target.value as CutSort);
|
|
setCurrentPage(1);
|
|
}}
|
|
className="h-11 rounded-xl border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text focus:border-brand-primary focus:outline-none cursor-pointer"
|
|
>
|
|
<option value="need_desc">Maior necessidade</option>
|
|
<option value="need_asc">Menor necessidade</option>
|
|
<option value="demand_desc">Maior demanda projetada</option>
|
|
<option value="stock_asc">Menor estoque</option>
|
|
<option value="sold_desc">Mais vendidos</option>
|
|
<option value="name_asc">Nome A-Z</option>
|
|
</select>
|
|
</div>
|
|
|
|
{isLoading && products.length === 0 ? (
|
|
<CuttingSkeleton />
|
|
) : (
|
|
<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-[940px] table-fixed text-left text-sm">
|
|
<colgroup>
|
|
<col className="w-[380px]" />
|
|
<col className="w-[120px]" />
|
|
<col className="w-[120px]" />
|
|
<col className="w-[140px]" />
|
|
<col className="w-[220px]" />
|
|
<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>
|
|
<th className="px-4 py-4 text-[10px] font-bold uppercase tracking-wider">Produto</th>
|
|
<th className="px-4 py-4 text-[10px] font-bold uppercase tracking-wider">Demanda</th>
|
|
<th className="px-4 py-4 text-[10px] font-bold uppercase tracking-wider">Estoque</th>
|
|
<th className="px-4 py-4 text-[10px] font-bold uppercase tracking-wider">Necessidade</th>
|
|
<th className="px-4 py-4 text-[10px] font-bold uppercase tracking-wider">Situação para corte</th>
|
|
<th className="px-4 py-4 text-right text-[10px] font-bold uppercase tracking-wider">Ações</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-zinc-100 dark:divide-dark-border">
|
|
{paginatedRows.map(row => (
|
|
<tr key={row.id} className="transition-colors hover:bg-zinc-50/80 dark:hover:bg-dark-input/50">
|
|
<td className="max-w-0 px-4 py-2.5">
|
|
<div className="truncate font-semibold text-zinc-900 dark:text-dark-text" title={row.name}>{row.name}</div>
|
|
<div className="mt-1 flex flex-wrap items-center gap-1.5">
|
|
<span className="font-mono text-[10px] font-medium text-zinc-400 dark:text-dark-muted">#{row.id}</span>
|
|
<span className={`inline-flex rounded-full border px-2 py-0.5 text-[10px] font-bold ${familyStyles[row.family.key]}`}>{row.family.materialLabel}</span>
|
|
<ProductColorBadge label={row.color} className="max-w-[92px] px-2 py-0.5 text-[10px]" />
|
|
<span className="inline-flex items-center gap-1 rounded-full border border-emerald-400/25 bg-emerald-400/10 px-2 py-0.5 text-[10px] font-bold text-emerald-300"><Ruler className="h-3 w-3" />{row.size || '-'}</span>
|
|
</div>
|
|
<div className="mt-1 text-[10px] font-medium text-zinc-400 dark:text-dark-muted">Média: {formatNumber(row.dailySales, 2)} un./dia · Cobertura: {formatDays(row.daysOfCover)}</div>
|
|
</td>
|
|
<td className="px-4 py-2.5 font-bold whitespace-nowrap text-zinc-900 dark:text-dark-text">{formatNumber(row.projectedDemand, 1)} un.</td>
|
|
<td className="px-4 py-2.5 whitespace-nowrap">
|
|
<div className="font-bold text-zinc-900 dark:text-dark-text">{formatNumber(row.stock)} un.</div>
|
|
{!!row.openProductionQuantity && (
|
|
<div className="text-[10px] font-semibold text-dark-muted">Disp. {formatNumber(row.availableQuantity)} un.</div>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-2.5 whitespace-nowrap">
|
|
<span className={row.suggestedCutQuantity > 0 ? 'font-bold text-red-300' : 'font-bold text-emerald-300'}>
|
|
{formatNumber(row.suggestedCutQuantity)} un.
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-2.5 align-middle">{renderCutStatus(row)}</td>
|
|
<td className="px-4 py-2.5 text-right">
|
|
<div className="flex justify-end gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => openQuickCutEdit(row)}
|
|
className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-dark-input text-dark-text transition-colors hover:bg-dark-border cursor-pointer"
|
|
title={`Editar dados de corte do SKU ${row.id}`}
|
|
aria-label={`Editar dados de corte do SKU ${row.id}`}
|
|
>
|
|
<Pencil className="h-3.5 w-3.5" />
|
|
</button>
|
|
<Link
|
|
to={`/products/${row.id}`}
|
|
className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-brand-primary/10 text-brand-primary transition-opacity hover:opacity-80"
|
|
title={`Ver SKU ${row.id}`}
|
|
aria-label={`Ver SKU ${row.id}`}
|
|
>
|
|
<Eye className="h-3.5 w-3.5" />
|
|
</Link>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{!filteredRows.length && (
|
|
<div className="px-6 py-12 text-center">
|
|
<Scissors className="mx-auto h-10 w-10 text-dark-muted" />
|
|
<p className="mt-4 text-sm font-bold text-dark-text">Nenhum item encontrado.</p>
|
|
<p className="mt-1 text-sm text-dark-muted">Ajuste a busca, família, status ou período selecionado.</p>
|
|
</div>
|
|
)}
|
|
|
|
<PaginationControls
|
|
totalItems={filteredRows.length}
|
|
currentPage={safeCurrentPage}
|
|
totalPages={totalPages}
|
|
pageSize={itemsPerPage}
|
|
pageSizeOptions={[10, 20, 50, 100]}
|
|
itemLabel="SKUs"
|
|
pageSizeLabel="itens por página"
|
|
startIndex={startIndex}
|
|
endIndex={Math.min(startIndex + itemsPerPage, filteredRows.length)}
|
|
onPageChange={setCurrentPage}
|
|
onPageSizeChange={(pageSize) => {
|
|
setItemsPerPage(pageSize);
|
|
setCurrentPage(1);
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default Cutting;
|