Refine material planning workflows
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m0s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m0s
This commit is contained in:
@@ -1790,6 +1790,12 @@ const getNeedPendingLabel = (need: SupplyPurchaseNeed) => {
|
||||
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 = (
|
||||
need: SupplyPurchaseNeed,
|
||||
field: 'suggestedQuantity' | 'quantitySold' | 'stockQuantity'
|
||||
@@ -1805,6 +1811,7 @@ const PurchaseNeedsScreen = () => {
|
||||
const [summary, setSummary] = useState<SupplySummary>(emptySupplySummary);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [activeTab, setActiveTab] = useState<PurchaseNeedTab>('materials');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(10);
|
||||
|
||||
@@ -1833,42 +1840,35 @@ 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 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)
|
||||
));
|
||||
const totalPages = Math.ceil(visibleNeeds.length / itemsPerPage);
|
||||
const safeCurrentPage = Math.min(currentPage, totalPages || 1);
|
||||
const startIndex = (safeCurrentPage - 1) * itemsPerPage;
|
||||
const paginatedNeeds = visibleNeeds.slice(startIndex, startIndex + itemsPerPage);
|
||||
const purchaseItemCount = summary.purchaseNeeds.filter(need => need.purchaseKg > 0).length;
|
||||
const suggestedPurchaseSummary = summarizePurchaseNeeds(summary.purchaseNeeds);
|
||||
const pendingSupplierCount = new Set(summary.purchaseNeeds.flatMap(need => need.suppliers)).size;
|
||||
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 purchaseItemCount = materialNeeds.filter(need => need.purchaseKg > 0).length;
|
||||
const suggestedPurchaseSummary = summarizePurchaseNeeds(materialNeeds);
|
||||
const impactedSkuCount = countImpactedSkus(mappingNeeds);
|
||||
const pageTitle = 'Necessidade de Compra';
|
||||
const pageSubtitle = setupMode
|
||||
? 'Complete referências de consumo para liberar o cálculo real de compra por material.'
|
||||
: 'Materiais abaixo do mínimo e necessidade projetada para compra.';
|
||||
const panelTitle = setupMode ? 'Pendências de cadastro' : 'Necessidade por material';
|
||||
const panelSubtitle = setupMode
|
||||
? 'Priorize os SKUs com maior impacto e cadastre material, rendimento e unidade de consumo.'
|
||||
: 'Planejado - estoque aprovado - recebimentos pendentes.';
|
||||
const stats = setupMode
|
||||
? [
|
||||
{ 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: 'Fornecedores', value: `${pendingSupplierCount}` },
|
||||
{ label: 'Sem referência', value: `${missingReferenceCount}` },
|
||||
];
|
||||
const pageSubtitle = 'Materiais, serviços terceirizados e pendências de cadastro em filas separadas.';
|
||||
const panelTitle = activeTab === 'materials' ? 'Compra de materiais' : activeTab === 'services' ? 'Serviços terceirizados' : 'Pendências de composição';
|
||||
const panelSubtitle = activeTab === 'materials'
|
||||
? 'Demanda de produto → material → saldo disponível → quantidade para comprar.'
|
||||
: activeTab === 'services'
|
||||
? 'Necessidades de beneficiamento e serviços. Não entram no total de compra de materiais.'
|
||||
: 'SKUs sem consumo confiável. Corrija o cadastro antes de usar estes valores para comprar.';
|
||||
const stats = [
|
||||
{ label: 'Materiais a comprar', value: `${purchaseItemCount}` },
|
||||
{ label: 'Compra sugerida', value: suggestedPurchaseSummary },
|
||||
{ label: 'Serviços a revisar', value: `${serviceNeeds.filter(need => need.purchaseKg > 0).length}` },
|
||||
{ label: 'SKUs sem mapeamento', value: `${impactedSkuCount || mappingNeeds.length}` },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={pageClassName}>
|
||||
@@ -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={() => exportCsv(setupMode ? 'pendencias-compra.csv' : 'necessidade-compra.csv', visibleNeeds.map(need => ({
|
||||
onClick={() => exportCsv(`necessidade-${activeTab}.csv`, visibleNeeds.map(need => ({
|
||||
material: getNeedDisplayName(need),
|
||||
pendencia: need.missingReference ? getNeedPendingLabel(need) : '',
|
||||
planejado: need.plannedKg,
|
||||
@@ -1920,9 +1920,20 @@ const PurchaseNeedsScreen = () => {
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
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>
|
||||
<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 ? (
|
||||
<div className={emptyStateClassName}>
|
||||
<BarChart3 className="h-8 w-8 text-brand-primary" />
|
||||
@@ -1931,7 +1942,7 @@ const PurchaseNeedsScreen = () => {
|
||||
) : visibleNeeds.length ? (
|
||||
<div className="mt-4 overflow-hidden rounded-xl border border-dark-border">
|
||||
<div className="overflow-x-auto">
|
||||
{setupMode ? (
|
||||
{activeTab === 'mapping' ? (
|
||||
<table className="w-full min-w-[880px] table-fixed border-collapse">
|
||||
<colgroup>
|
||||
<col className="w-[34%]" />
|
||||
@@ -1997,9 +2008,9 @@ const PurchaseNeedsScreen = () => {
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<table className="w-full min-w-[1120px] table-fixed border-collapse">
|
||||
<table className="w-full min-w-[980px] table-fixed border-collapse">
|
||||
<colgroup>
|
||||
<col className="w-[280px]" />
|
||||
<col className="w-[210px]" />
|
||||
<col />
|
||||
<col className="w-[120px]" />
|
||||
<col className="w-[120px]" />
|
||||
@@ -2009,13 +2020,13 @@ const PurchaseNeedsScreen = () => {
|
||||
</colgroup>
|
||||
<thead className="border-b border-dark-border bg-dark-input/40 text-xs font-bold uppercase tracking-widest text-dark-muted">
|
||||
<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">Material necessário</th>
|
||||
<th scope="col" className="px-4 py-3 text-right">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">Pendente</th>
|
||||
<th scope="col" className="px-4 py-3 text-right">Comprar</th>
|
||||
<th scope="col" className="px-4 py-3 text-left">Cobertura</th>
|
||||
<th scope="col" className="px-4 py-3 text-left">Demanda</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">{activeTab === 'services' ? 'Volume previsto' : 'Necessário'}</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">{activeTab === 'services' ? 'Em aberto' : 'Pendente'}</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">{activeTab === 'services' ? 'Ação' : 'Cobertura'}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-dark-border bg-dark-card">
|
||||
@@ -2025,14 +2036,10 @@ const PurchaseNeedsScreen = () => {
|
||||
<tr key={need.material}>
|
||||
<td className="px-4 py-4 align-middle">
|
||||
{need.products?.length ? (
|
||||
<div className="min-w-0">
|
||||
{need.products.slice(0, 2).map(product => (
|
||||
<div key={product.productId} className="mb-1 min-w-0 last:mb-0" title={product.name}>
|
||||
<p className="truncate text-xs font-bold text-dark-text">{product.name || product.productId}</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>
|
||||
<p className="text-sm font-bold text-dark-text">{need.products.length} {need.products.length === 1 ? 'SKU impactado' : 'SKUs impactados'}</p>
|
||||
<p className="mt-1 text-xs font-semibold text-dark-muted">Demanda: {formatNumber(sumNeedProducts(need, 'suggestedQuantity'))} un.</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>
|
||||
</div>
|
||||
) : <span className="text-xs font-semibold text-dark-muted">Plano manual</span>}
|
||||
</td>
|
||||
@@ -2056,21 +2063,17 @@ const PurchaseNeedsScreen = () => {
|
||||
</RouterLink>
|
||||
) : null}
|
||||
</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>
|
||||
</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">{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.stockKg)}</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 align-middle">
|
||||
<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'
|
||||
: need.status === 'critical'
|
||||
? '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-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>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
Reference in New Issue
Block a user