Refine material planning workflows
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m0s

This commit is contained in:
Cauê Faleiros
2026-08-03 10:57:47 -03:00
parent 7fcf52cf67
commit 635103b924
3 changed files with 124 additions and 82 deletions

View File

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

View File

@@ -340,7 +340,12 @@ const ProductDetails = () => {
.replace(/\s+/g, ' ') .replace(/\s+/g, ' ')
.trim() .trim()
.toUpperCase(); .toUpperCase();
const isServiceComponent = (name: string) => /\b(?:SERVI[CÇ]O|FRETE|TINTURARIA|TECELAGEM|COSTURA|ESTAMPARIA|LAVANDERIA)\b/i.test(name);
const isSuspiciousConsumption = (quantity: number, unit: string) => (
['kg', 'quilo', 'quilos'].includes(unit.trim().toLowerCase()) && quantity >= 0.5
);
const materialPlan = (composition?.components || []).map(component => { const materialPlan = (composition?.components || []).map(component => {
const isService = isServiceComponent(component.componentName);
const candidates = new Set([ const candidates = new Set([
component.componentTinyId, component.componentTinyId,
component.componentSku, component.componentSku,
@@ -350,7 +355,7 @@ const ProductDetails = () => {
const stockMatch = materialStock.find(item => ( const stockMatch = materialStock.find(item => (
candidates.has(normalizeMaterialKey(item.produto_id)) || candidates.has(normalizeMaterialKey(item.nome)) candidates.has(normalizeMaterialKey(item.produto_id)) || candidates.has(normalizeMaterialKey(item.nome))
)); ));
const availableStock = stockMatch ? getPlanningStock(stockMatch.saldo) : null; const availableStock = isService ? null : stockMatch ? getPlanningStock(stockMatch.saldo) : null;
const requiredForPeriod = totalSold * component.quantityPerUnit; const requiredForPeriod = totalSold * component.quantityPerUnit;
const productionCapacity = availableStock === null || component.quantityPerUnit <= 0 const productionCapacity = availableStock === null || component.quantityPerUnit <= 0
? null ? null
@@ -358,10 +363,10 @@ const ProductDetails = () => {
const periodCoverage = requiredForPeriod > 0 && availableStock !== null const periodCoverage = requiredForPeriod > 0 && availableStock !== null
? (availableStock / requiredForPeriod) * periodDays ? (availableStock / requiredForPeriod) * periodDays
: null; : null;
return { component, availableStock, requiredForPeriod, productionCapacity, periodCoverage }; return { component, availableStock, requiredForPeriod, productionCapacity, periodCoverage, isService, suspicious: isSuspiciousConsumption(component.quantityPerUnit, component.unit) };
}); });
const blockingMaterial = materialPlan const blockingMaterial = materialPlan
.filter(item => item.productionCapacity !== null) .filter(item => !item.isService && item.productionCapacity !== null)
.sort((a, b) => (a.productionCapacity || 0) - (b.productionCapacity || 0))[0]; .sort((a, b) => (a.productionCapacity || 0) - (b.productionCapacity || 0))[0];
return ( return (
@@ -477,20 +482,21 @@ const ProductDetails = () => {
</div> </div>
) : ( ) : (
<div className="overflow-x-auto rounded-xl border border-dark-border"> <div className="overflow-x-auto rounded-xl border border-dark-border">
<table className="w-full min-w-[920px] text-left text-sm"> <table className="w-full min-w-[1040px] text-left text-sm">
<thead className="bg-dark-input/60 text-[10px] font-bold uppercase tracking-widest text-dark-muted"> <thead className="bg-dark-input/60 text-[10px] font-bold uppercase tracking-widest text-dark-muted">
<tr> <tr>
<th className="px-4 py-3">Produto / insumo</th> <th className="px-4 py-3">Produto / insumo</th>
<th className="px-4 py-3">SKU</th> <th className="px-4 py-3">SKU</th>
<th className="px-4 py-3 text-right">Quantidade por unidade</th> <th className="px-4 py-3 text-right">Quantidade por unidade</th>
<th className="px-4 py-3">Unidade</th> <th className="px-4 py-3">Unidade</th>
<th className="px-4 py-3">Validação</th>
<th className="px-4 py-3 text-right">Necessário no período</th> <th className="px-4 py-3 text-right">Necessário no período</th>
<th className="px-4 py-3 text-right">Estoque Tiny</th> <th className="px-4 py-3 text-right">Estoque Tiny</th>
<th className="px-4 py-3 text-right">Cobertura</th> <th className="px-4 py-3 text-right">Cobertura</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-dark-border"> <tbody className="divide-y divide-dark-border">
{materialPlan.map(({ component, requiredForPeriod, availableStock, productionCapacity, periodCoverage }) => ( {materialPlan.map(({ component, requiredForPeriod, availableStock, productionCapacity, periodCoverage, isService, suspicious }) => (
<tr key={component.id} className="text-dark-text"> <tr key={component.id} className="text-dark-text">
<td className="px-4 py-3 font-semibold"> <td className="px-4 py-3 font-semibold">
{component.productId ? ( {component.productId ? (
@@ -502,12 +508,18 @@ const ProductDetails = () => {
<td className="px-4 py-3 font-mono text-xs text-dark-muted">{component.componentSku || '—'}</td> <td className="px-4 py-3 font-mono text-xs text-dark-muted">{component.componentSku || '—'}</td>
<td className="px-4 py-3 text-right font-semibold">{formatNumber(component.quantityPerUnit)}</td> <td className="px-4 py-3 text-right font-semibold">{formatNumber(component.quantityPerUnit)}</td>
<td className="px-4 py-3 text-dark-muted">{component.unit || '—'}</td> <td className="px-4 py-3 text-dark-muted">{component.unit || '—'}</td>
<td className="px-4 py-3">
{isService ? <span className="rounded-full border border-sky-400/30 bg-sky-400/10 px-2 py-1 text-[10px] font-bold text-sky-300">Serviço</span>
: suspicious ? <span className="rounded-full border border-amber-400/30 bg-amber-400/10 px-2 py-1 text-[10px] font-bold text-amber-300">Revisar consumo</span>
: <span className="text-xs font-bold text-emerald-300">OK</span>}
</td>
<td className="px-4 py-3 text-right font-semibold">{formatNumber(requiredForPeriod)} {component.unit || ''}</td> <td className="px-4 py-3 text-right font-semibold">{formatNumber(requiredForPeriod)} {component.unit || ''}</td>
<td className="px-4 py-3 text-right font-semibold">{availableStock === null ? 'Sem vínculo' : `${formatNumber(availableStock)} ${component.unit || ''}`}</td> <td className="px-4 py-3 text-right font-semibold">{isService ? 'Não aplicável' : availableStock === null ? 'Sem vínculo' : `${formatNumber(availableStock)} ${component.unit || ''}`}</td>
<td className={`px-4 py-3 text-right font-bold ${availableStock === null || (productionCapacity || 0) < totalSold ? 'text-red-300' : 'text-emerald-300'}`}> <td className={`px-4 py-3 text-right font-bold ${isService ? 'text-sky-300' : availableStock === null || (productionCapacity || 0) < totalSold ? 'text-red-300' : 'text-emerald-300'}`}>
{availableStock === null {isService ? 'Terceirizar'
: availableStock === null
? 'Revisar link' ? 'Revisar link'
: `${formatNumber(productionCapacity || 0)} un. · ${periodCoverage === null ? '-' : `${formatNumber(periodCoverage)} dias`}`} : `${formatNumber(productionCapacity || 0)} un. · ${periodCoverage === null ? '-' : periodCoverage > 365 ? '> 365 dias' : `${formatNumber(periodCoverage)} dias`}`}
</td> </td>
</tr> </tr>
))} ))}

View File

@@ -1790,6 +1790,12 @@ const getNeedPendingLabel = (need: SupplyPurchaseNeed) => {
return /^Cadastrar rendimento:/i.test(need.material) ? 'Rendimento' : 'Referência'; return /^Cadastrar rendimento:/i.test(need.material) ? 'Rendimento' : 'Referência';
}; };
type PurchaseNeedTab = 'materials' | 'services' | 'mapping';
const isServicePurchaseNeed = (need: SupplyPurchaseNeed) => (
/\b(?:SERVI[CÇ]O|FRETE|TINTURARIA|TECELAGEM|COSTURA|ESTAMPARIA|LAVANDERIA)\b/i.test(need.material)
);
const sumNeedProducts = ( const sumNeedProducts = (
need: SupplyPurchaseNeed, need: SupplyPurchaseNeed,
field: 'suggestedQuantity' | 'quantitySold' | 'stockQuantity' field: 'suggestedQuantity' | 'quantitySold' | 'stockQuantity'
@@ -1805,6 +1811,7 @@ const PurchaseNeedsScreen = () => {
const [summary, setSummary] = useState<SupplySummary>(emptySupplySummary); const [summary, setSummary] = useState<SupplySummary>(emptySupplySummary);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [activeTab, setActiveTab] = useState<PurchaseNeedTab>('materials');
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(10); const [itemsPerPage, setItemsPerPage] = useState(10);
@@ -1833,41 +1840,34 @@ const PurchaseNeedsScreen = () => {
}; };
}, []); }, []);
const materialNeeds = summary.purchaseNeeds.filter(need => !need.missingReference && !isServicePurchaseNeed(need));
const serviceNeeds = summary.purchaseNeeds.filter(need => !need.missingReference && isServicePurchaseNeed(need));
const mappingNeeds = summary.purchaseNeeds.filter(need => need.missingReference);
const activeNeeds = activeTab === 'materials' ? materialNeeds : activeTab === 'services' ? serviceNeeds : mappingNeeds;
const normalizedSearch = normalizeSearch(search); const normalizedSearch = normalizeSearch(search);
const visibleNeeds = summary.purchaseNeeds.filter(need => ( const visibleNeeds = activeNeeds.filter(need => (
!normalizedSearch || normalizeSearch(`${need.material} ${getNeedDisplayName(need)} ${need.suppliers.join(' ')} ${need.colors.join(' ')} ${(need.products || []).map(product => `${product.productId} ${product.name}`).join(' ')}`).includes(normalizedSearch) !normalizedSearch || normalizeSearch(`${need.material} ${getNeedDisplayName(need)} ${need.suppliers.join(' ')} ${need.colors.join(' ')} ${(need.products || []).map(product => `${product.productId} ${product.name}`).join(' ')}`).includes(normalizedSearch)
)); ));
const totalPages = Math.ceil(visibleNeeds.length / itemsPerPage); const totalPages = Math.ceil(visibleNeeds.length / itemsPerPage);
const safeCurrentPage = Math.min(currentPage, totalPages || 1); const safeCurrentPage = Math.min(currentPage, totalPages || 1);
const startIndex = (safeCurrentPage - 1) * itemsPerPage; const startIndex = (safeCurrentPage - 1) * itemsPerPage;
const paginatedNeeds = visibleNeeds.slice(startIndex, startIndex + itemsPerPage); const paginatedNeeds = visibleNeeds.slice(startIndex, startIndex + itemsPerPage);
const purchaseItemCount = summary.purchaseNeeds.filter(need => need.purchaseKg > 0).length; const purchaseItemCount = materialNeeds.filter(need => need.purchaseKg > 0).length;
const suggestedPurchaseSummary = summarizePurchaseNeeds(summary.purchaseNeeds); const suggestedPurchaseSummary = summarizePurchaseNeeds(materialNeeds);
const pendingSupplierCount = new Set(summary.purchaseNeeds.flatMap(need => need.suppliers)).size; const impactedSkuCount = countImpactedSkus(mappingNeeds);
const missingReferenceCount = summary.purchaseNeeds.filter(need => need.missingReference).length;
const mappedNeedCount = summary.purchaseNeeds.length - missingReferenceCount;
const impactedSkuCount = countImpactedSkus(summary.purchaseNeeds);
const setupMode = missingReferenceCount > 0 && missingReferenceCount >= mappedNeedCount;
const pageTitle = 'Necessidade de Compra'; const pageTitle = 'Necessidade de Compra';
const pageSubtitle = setupMode const pageSubtitle = 'Materiais, serviços terceirizados e pendências de cadastro em filas separadas.';
? 'Complete referências de consumo para liberar o cálculo real de compra por material.' const panelTitle = activeTab === 'materials' ? 'Compra de materiais' : activeTab === 'services' ? 'Serviços terceirizados' : 'Pendências de composição';
: 'Materiais abaixo do mínimo e necessidade projetada para compra.'; const panelSubtitle = activeTab === 'materials'
const panelTitle = setupMode ? 'Pendências de cadastro' : 'Necessidade por material'; ? 'Demanda de produto → material → saldo disponível → quantidade para comprar.'
const panelSubtitle = setupMode : activeTab === 'services'
? 'Priorize os SKUs com maior impacto e cadastre material, rendimento e unidade de consumo.' ? 'Necessidades de beneficiamento e serviços. Não entram no total de compra de materiais.'
: 'Planejado - estoque aprovado - recebimentos pendentes.'; : 'SKUs sem consumo confiável. Corrija o cadastro antes de usar estes valores para comprar.';
const stats = setupMode const stats = [
? [ { label: 'Materiais a comprar', value: `${purchaseItemCount}` },
{ label: 'Pendências', value: `${missingReferenceCount}` },
{ label: 'SKUs impactados', value: `${impactedSkuCount || missingReferenceCount}` },
{ label: 'Impacto estimado', value: suggestedPurchaseSummary },
{ label: 'Referências prontas', value: `${mappedNeedCount}` },
]
: [
{ label: 'Itens a comprar', value: `${purchaseItemCount}` },
{ label: 'Compra sugerida', value: suggestedPurchaseSummary }, { label: 'Compra sugerida', value: suggestedPurchaseSummary },
{ label: 'Fornecedores', value: `${pendingSupplierCount}` }, { label: 'Serviços a revisar', value: `${serviceNeeds.filter(need => need.purchaseKg > 0).length}` },
{ label: 'Sem referência', value: `${missingReferenceCount}` }, { label: 'SKUs sem mapeamento', value: `${impactedSkuCount || mappingNeeds.length}` },
]; ];
return ( return (
@@ -1891,7 +1891,7 @@ const PurchaseNeedsScreen = () => {
<button type="button" onClick={loadSummary} className={buttonClassName}><RefreshCw className="h-4 w-4" /> Atualizar</button> <button type="button" onClick={loadSummary} className={buttonClassName}><RefreshCw className="h-4 w-4" /> Atualizar</button>
<button <button
type="button" type="button"
onClick={() => exportCsv(setupMode ? 'pendencias-compra.csv' : 'necessidade-compra.csv', visibleNeeds.map(need => ({ onClick={() => exportCsv(`necessidade-${activeTab}.csv`, visibleNeeds.map(need => ({
material: getNeedDisplayName(need), material: getNeedDisplayName(need),
pendencia: need.missingReference ? getNeedPendingLabel(need) : '', pendencia: need.missingReference ? getNeedPendingLabel(need) : '',
planejado: need.plannedKg, planejado: need.plannedKg,
@@ -1920,9 +1920,20 @@ const PurchaseNeedsScreen = () => {
setCurrentPage(1); setCurrentPage(1);
}} }}
className={`${inputClassName} pl-9`} className={`${inputClassName} pl-9`}
placeholder={setupMode ? 'Buscar por SKU, produto ou pendência...' : 'Buscar por material, fornecedor ou cor...'} placeholder={activeTab === 'mapping' ? 'Buscar por SKU, produto ou pendência...' : activeTab === 'services' ? 'Buscar por serviço ou SKU impactado...' : 'Buscar por material, fornecedor ou cor...'}
/> />
</label> </label>
<div className="mt-4 inline-flex rounded-xl border border-dark-border bg-dark-input p-1">
{[
{ id: 'materials' as const, label: `Materiais (${materialNeeds.length})` },
{ id: 'services' as const, label: `Serviços (${serviceNeeds.length})` },
{ id: 'mapping' as const, label: `Mapear dados (${mappingNeeds.length})` },
].map(tab => (
<button key={tab.id} type="button" onClick={() => { setActiveTab(tab.id); setSearch(''); setCurrentPage(1); }} className={`rounded-lg px-3 py-2 text-xs font-bold transition-colors cursor-pointer ${activeTab === tab.id ? 'bg-brand-primary text-brand-contrast' : 'text-dark-muted hover:text-dark-text'}`}>
{tab.label}
</button>
))}
</div>
{isLoading ? ( {isLoading ? (
<div className={emptyStateClassName}> <div className={emptyStateClassName}>
<BarChart3 className="h-8 w-8 text-brand-primary" /> <BarChart3 className="h-8 w-8 text-brand-primary" />
@@ -1931,7 +1942,7 @@ const PurchaseNeedsScreen = () => {
) : visibleNeeds.length ? ( ) : visibleNeeds.length ? (
<div className="mt-4 overflow-hidden rounded-xl border border-dark-border"> <div className="mt-4 overflow-hidden rounded-xl border border-dark-border">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
{setupMode ? ( {activeTab === 'mapping' ? (
<table className="w-full min-w-[880px] table-fixed border-collapse"> <table className="w-full min-w-[880px] table-fixed border-collapse">
<colgroup> <colgroup>
<col className="w-[34%]" /> <col className="w-[34%]" />
@@ -1997,9 +2008,9 @@ const PurchaseNeedsScreen = () => {
</tbody> </tbody>
</table> </table>
) : ( ) : (
<table className="w-full min-w-[1120px] table-fixed border-collapse"> <table className="w-full min-w-[980px] table-fixed border-collapse">
<colgroup> <colgroup>
<col className="w-[280px]" /> <col className="w-[210px]" />
<col /> <col />
<col className="w-[120px]" /> <col className="w-[120px]" />
<col className="w-[120px]" /> <col className="w-[120px]" />
@@ -2009,13 +2020,13 @@ const PurchaseNeedsScreen = () => {
</colgroup> </colgroup>
<thead className="border-b border-dark-border bg-dark-input/40 text-xs font-bold uppercase tracking-widest text-dark-muted"> <thead className="border-b border-dark-border bg-dark-input/40 text-xs font-bold uppercase tracking-widest text-dark-muted">
<tr> <tr>
<th scope="col" className="px-4 py-3 text-left">Demanda de produto</th> <th scope="col" className="px-4 py-3 text-left">Demanda</th>
<th scope="col" className="px-4 py-3 text-left">Material necessário</th> <th scope="col" className="px-4 py-3 text-left">{activeTab === 'services' ? 'Serviço' : 'Material'}</th>
<th scope="col" className="px-4 py-3 text-right">Necessário</th> <th scope="col" className="px-4 py-3 text-right">{activeTab === 'services' ? 'Volume previsto' : 'Necessário'}</th>
<th scope="col" className="px-4 py-3 text-right">Estoque atual</th> <th scope="col" className="px-4 py-3 text-right">{activeTab === 'services' ? 'Agendado' : 'Estoque atual'}</th>
<th scope="col" className="px-4 py-3 text-right">Pendente</th> <th scope="col" className="px-4 py-3 text-right">{activeTab === 'services' ? 'Em aberto' : 'Pendente'}</th>
<th scope="col" className="px-4 py-3 text-right">Comprar</th> <th scope="col" className="px-4 py-3 text-right">{activeTab === 'services' ? 'Contratar' : 'Comprar'}</th>
<th scope="col" className="px-4 py-3 text-left">Cobertura</th> <th scope="col" className="px-4 py-3 text-left">{activeTab === 'services' ? 'Ação' : 'Cobertura'}</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-dark-border bg-dark-card"> <tbody className="divide-y divide-dark-border bg-dark-card">
@@ -2025,14 +2036,10 @@ const PurchaseNeedsScreen = () => {
<tr key={need.material}> <tr key={need.material}>
<td className="px-4 py-4 align-middle"> <td className="px-4 py-4 align-middle">
{need.products?.length ? ( {need.products?.length ? (
<div className="min-w-0"> <div>
{need.products.slice(0, 2).map(product => ( <p className="text-sm font-bold text-dark-text">{need.products.length} {need.products.length === 1 ? 'SKU impactado' : 'SKUs impactados'}</p>
<div key={product.productId} className="mb-1 min-w-0 last:mb-0" title={product.name}> <p className="mt-1 text-xs font-semibold text-dark-muted">Demanda: {formatNumber(sumNeedProducts(need, 'suggestedQuantity'))} un.</p>
<p className="truncate text-xs font-bold text-dark-text">{product.name || product.productId}</p> <p className="mt-1 truncate font-mono text-[10px] text-dark-muted" title={need.products.map(product => product.productId).join(', ')}>{need.products.slice(0, 3).map(product => product.productId).join(', ')}{need.products.length > 3 ? ` +${need.products.length - 3}` : ''}</p>
<p className="text-[10px] font-semibold text-dark-muted">SKU {product.productId} · demanda {formatNumber(product.suggestedQuantity)} un.</p>
</div>
))}
{need.products.length > 2 && <p className="mt-1 text-[10px] font-semibold text-dark-muted">+{need.products.length - 2} SKUs impactados</p>}
</div> </div>
) : <span className="text-xs font-semibold text-dark-muted">Plano manual</span>} ) : <span className="text-xs font-semibold text-dark-muted">Plano manual</span>}
</td> </td>
@@ -2056,21 +2063,17 @@ const PurchaseNeedsScreen = () => {
</RouterLink> </RouterLink>
) : null} ) : null}
</div> </div>
{need.products?.length ? (
<p className="mt-1 text-xs font-semibold text-dark-muted">
{need.products.slice(0, 2).map(product => product.productId).join(', ')}
{need.products.length > 2 ? ` +${need.products.length - 2}` : ''}
</p>
) : null}
</div> </div>
</td> </td>
<td className="px-4 py-4 text-right align-middle text-sm font-bold text-dark-text">{formatNeedQuantity(need, need.plannedKg)}</td> <td className="px-4 py-4 text-right align-middle text-sm font-bold text-dark-text">{formatNeedQuantity(need, need.plannedKg)}</td>
<td className="px-4 py-4 text-right align-middle text-sm font-bold text-dark-text">{formatNeedQuantity(need, need.stockKg)}</td> <td className="px-4 py-4 text-right align-middle text-sm font-bold text-dark-text">{activeTab === 'services' ? '—' : formatNeedQuantity(need, need.stockKg)}</td>
<td className="px-4 py-4 text-right align-middle text-sm font-bold text-dark-text">{formatNeedQuantity(need, need.pendingKg)}</td> <td className="px-4 py-4 text-right align-middle text-sm font-bold text-dark-text">{activeTab === 'services' ? '—' : formatNeedQuantity(need, need.pendingKg)}</td>
<td className={`px-4 py-4 text-right align-middle text-sm font-bold ${need.purchaseKg > 0 ? 'text-amber-300' : 'text-emerald-300'}`}>{formatNeedQuantity(need, need.purchaseKg)}</td> <td className={`px-4 py-4 text-right align-middle text-sm font-bold ${need.purchaseKg > 0 ? 'text-amber-300' : 'text-emerald-300'}`}>{formatNeedQuantity(need, need.purchaseKg)}</td>
<td className="px-4 py-4 align-middle"> <td className="px-4 py-4 align-middle">
<span className={`inline-flex whitespace-nowrap rounded-full border px-2.5 py-1 text-xs font-bold ${ <span className={`inline-flex whitespace-nowrap rounded-full border px-2.5 py-1 text-xs font-bold ${
need.missingReference activeTab === 'services'
? 'border-sky-400/30 bg-sky-400/10 text-sky-300'
: need.missingReference
? 'border-red-400/30 bg-red-400/10 text-red-300' ? 'border-red-400/30 bg-red-400/10 text-red-300'
: need.status === 'critical' : need.status === 'critical'
? 'border-red-400/30 bg-red-400/10 text-red-300' ? 'border-red-400/30 bg-red-400/10 text-red-300'
@@ -2078,7 +2081,7 @@ const PurchaseNeedsScreen = () => {
? 'border-amber-400/30 bg-amber-400/10 text-amber-300' ? 'border-amber-400/30 bg-amber-400/10 text-amber-300'
: 'border-emerald-400/30 bg-emerald-400/10 text-emerald-300' : 'border-emerald-400/30 bg-emerald-400/10 text-emerald-300'
}`}> }`}>
{need.missingReference ? 'Sem referência' : purchaseStatusLabels[need.status]} {activeTab === 'services' ? 'Programar serviço' : need.missingReference ? 'Sem referência' : purchaseStatusLabels[need.status]}
</span> </span>
</td> </td>
</tr> </tr>