Simplify purchase need actions

This commit is contained in:
Cauê Faleiros
2026-08-10 09:45:59 -03:00
parent 3f8e5eeb0c
commit 10eda267fa

View File

@@ -9,6 +9,7 @@ import {
ClipboardCheck,
ClipboardList,
Download,
Eye,
Link as LinkIcon,
Package,
PackageSearch,
@@ -2099,26 +2100,55 @@ const getNeedDisplayName = (need: SupplyPurchaseNeed) => (
need.material.replace(/^Cadastrar (consumo|rendimento):\s*/i, '')
);
const getNeedPendingLabel = (need: SupplyPurchaseNeed) => {
if (!need.missingReference) return purchaseStatusLabels[need.status];
return /^Cadastrar rendimento:/i.test(need.material) ? 'Rendimento' : 'Referência';
};
const hasNegativeMaterialBalance = (need: SupplyPurchaseNeed) => need.stockKg < 0;
type PurchaseNeedTab = 'materials' | 'services' | 'mapping';
type PurchaseNeedTab = 'materials' | 'services';
type MaterialPurchaseFilter = 'to_buy' | 'negative_balance' | 'no_stock' | 'all';
const sumNeedProducts = (
need: SupplyPurchaseNeed,
field: 'suggestedQuantity' | 'quantitySold' | 'stockQuantity'
field: 'suggestedQuantity'
) => (
(need.products || []).reduce((total, product) => total + Number(product[field] || 0), 0)
);
const countImpactedSkus = (needs: SupplyPurchaseNeed[]) => (
new Set(needs.flatMap(need => (need.products || []).map(product => product.productId).filter(Boolean))).size
);
const AffectedProducts = ({ products }: { products: SupplyPurchaseNeed['products'] }) => {
if (!products?.length) return null;
return (
<div className="mt-3 flex flex-wrap items-center gap-2 border-t border-dark-border/70 pt-3">
<span className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">Produtos afetados</span>
{products.slice(0, 3).map(product => (
<div key={product.productId} className="flex max-w-full items-center overflow-hidden rounded-md border border-dark-border bg-dark-input">
<RouterLink
to={`/products/${product.productId}`}
className="min-w-0 cursor-pointer truncate px-2 py-1 text-[11px] font-bold text-dark-muted transition-colors hover:text-brand-primary"
title={product.name}
>
#{product.productId} · {product.name}
</RouterLink>
<RouterLink
to={buildConsumptionReferencePath({ sku: product.productId, name: product.name })}
className="flex h-7 w-7 shrink-0 items-center justify-center border-l border-dark-border text-sky-300 transition-colors hover:bg-sky-400/10"
title={`Editar composição de ${product.name}`}
aria-label={`Editar composição de ${product.name}`}
>
<Pencil className="h-3.5 w-3.5" />
</RouterLink>
<RouterLink
to={`/products/${product.productId}`}
className="flex h-7 w-7 shrink-0 items-center justify-center border-l border-dark-border text-brand-primary transition-colors hover:bg-brand-primary/10"
title={`Ver produto ${product.name}`}
aria-label={`Ver produto ${product.name}`}
>
<Eye className="h-3.5 w-3.5" />
</RouterLink>
</div>
))}
{products.length > 3 && <span className="text-xs font-bold text-dark-muted">+{products.length - 3} SKUs</span>}
</div>
);
};
const PurchaseMaterialQueue = ({ needs }: { needs: SupplyPurchaseNeed[] }) => (
<>
@@ -2192,22 +2222,7 @@ const PurchaseMaterialQueue = ({ needs }: { needs: SupplyPurchaseNeed[] }) => (
</div>
</div>
{products.length > 0 && (
<div className="mt-3 flex flex-wrap items-center gap-2 border-t border-dark-border/70 pt-3">
<span className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">Produtos afetados</span>
{products.slice(0, 3).map(product => (
<RouterLink
key={product.productId}
to={`/products/${product.productId}`}
className="max-w-full cursor-pointer truncate rounded-md border border-dark-border bg-dark-input px-2 py-1 text-[11px] font-bold text-dark-muted transition-colors hover:border-brand-primary hover:text-brand-primary"
title={product.name}
>
#{product.productId} · {product.name}
</RouterLink>
))}
{products.length > 3 && <span className="text-xs font-bold text-dark-muted">+{products.length - 3} SKUs</span>}
</div>
)}
<AffectedProducts products={products} />
</article>
);
})}
@@ -2264,85 +2279,7 @@ const PurchaseServiceQueue = ({ needs }: { needs: SupplyPurchaseNeed[] }) => (
</div>
</div>
{products.length > 0 && (
<div className="mt-3 flex flex-wrap items-center gap-2 border-t border-dark-border/70 pt-3">
<span className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">Produtos afetados</span>
{products.slice(0, 3).map(product => (
<RouterLink key={product.productId} to={`/products/${product.productId}`} className="max-w-full cursor-pointer truncate rounded-md border border-dark-border bg-dark-input px-2 py-1 text-[11px] font-bold text-dark-muted transition-colors hover:border-brand-primary hover:text-brand-primary" title={product.name}>
#{product.productId} · {product.name}
</RouterLink>
))}
{products.length > 3 && <span className="text-xs font-bold text-dark-muted">+{products.length - 3} SKUs</span>}
</div>
)}
</article>
);
})}
</div>
</>
);
const PurchaseMappingQueue = ({ needs }: { needs: SupplyPurchaseNeed[] }) => (
<>
<div className="border-b border-dark-border bg-dark-input/40 px-4 py-3">
<div className="grid gap-3 text-[10px] font-bold uppercase tracking-widest text-dark-muted xl:grid-cols-[minmax(330px,1.35fr)_minmax(380px,1fr)_180px]">
<span>SKU sem composição confiável</span>
<span className="hidden xl:block">Impacto antes de comprar</span>
<span className="hidden text-right xl:block">Correção</span>
</div>
</div>
<div className="divide-y divide-dark-border">
{needs.map(need => {
const referenceProduct = need.products?.[0];
const productName = referenceProduct?.name || getNeedDisplayName(need);
const productId = referenceProduct?.productId || '';
const soldQuantity = referenceProduct?.quantitySold ?? sumNeedProducts(need, 'quantitySold');
const stockQuantity = referenceProduct?.stockQuantity ?? sumNeedProducts(need, 'stockQuantity');
return (
<article key={need.material} className="border-l-2 border-l-sky-400 px-4 py-4 transition-colors hover:bg-dark-input/35">
<div className="grid gap-4 xl:grid-cols-[minmax(330px,1.35fr)_minmax(380px,1fr)_180px] xl:items-center">
<div className="min-w-0">
{productId ? (
<RouterLink to={`/products/${productId}`} className="block truncate text-sm font-bold text-dark-text transition-colors hover:text-brand-primary" title={productName}>{productName}</RouterLink>
) : (
<h3 className="truncate text-sm font-bold text-dark-text">{productName}</h3>
)}
<div className="mt-1 flex flex-wrap items-center gap-2">
<span className="font-mono text-xs font-semibold text-dark-muted">{productId ? `SKU ${productId}` : 'SKU não informado'}</span>
<span className="inline-flex rounded-full border border-sky-400/30 bg-sky-400/10 px-2 py-0.5 text-[10px] font-bold text-sky-300">{getNeedPendingLabel(need)}</span>
</div>
</div>
<div className="grid grid-cols-3 gap-2">
<div className="rounded-lg bg-dark-input/75 px-3 py-2">
<p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Vendido</p>
<p className="mt-1 text-sm font-bold text-dark-text">{formatNumber(soldQuantity, 0)} un.</p>
</div>
<div className="rounded-lg bg-dark-input/75 px-3 py-2">
<p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Estoque produto</p>
<p className={`mt-1 text-sm font-bold ${stockQuantity < 0 ? 'text-red-300' : 'text-dark-text'}`}>{formatNumber(stockQuantity, 0)} un.</p>
</div>
<div className="rounded-lg border border-amber-400/20 bg-amber-400/[0.06] px-3 py-2">
<p className="text-[10px] font-bold uppercase tracking-wider text-amber-300">Necessidade estimada</p>
<p className="mt-1 text-sm font-bold text-amber-300">{formatNeedQuantity(need, need.purchaseKg)}</p>
</div>
</div>
<div className="xl:text-right">
{referenceProduct ? (
<RouterLink
to={buildConsumptionReferencePath({ sku: referenceProduct.productId, name: referenceProduct.name })}
className="inline-flex h-10 cursor-pointer items-center justify-center gap-2 rounded-lg border border-sky-400/30 bg-sky-400/10 px-3 text-xs font-bold text-sky-300 transition-colors hover:bg-sky-400/20"
>
<Pencil className="h-3.5 w-3.5" />
Mapear consumo
</RouterLink>
) : (
<span className="text-xs font-bold text-dark-muted">SKU necessário para corrigir</span>
)}
</div>
</div>
<AffectedProducts products={products} />
</article>
);
})}
@@ -2357,7 +2294,7 @@ const PurchaseNeedsScreen = () => {
const [search, setSearch] = useState('');
const requestedTab = searchParams.get('tab');
const requestedFilter = searchParams.get('filter');
const activeTab: PurchaseNeedTab = requestedTab === 'services' || requestedTab === 'mapping' ? requestedTab : 'materials';
const activeTab: PurchaseNeedTab = requestedTab === 'services' ? 'services' : 'materials';
const materialFilter: MaterialPurchaseFilter = requestedFilter === 'negative_balance' || requestedFilter === 'no_stock' || requestedFilter === 'all' ? requestedFilter : 'to_buy';
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(10);
@@ -2395,7 +2332,6 @@ 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 purchaseNeeds = materialNeeds.filter(need => need.purchaseKg > 0);
const negativeBalanceNeeds = materialNeeds.filter(hasNegativeMaterialBalance);
const noStockNeeds = materialNeeds.filter(need => need.stockKg === 0 && need.purchaseKg > 0);
@@ -2411,7 +2347,7 @@ const PurchaseNeedsScreen = () => {
if (a.status !== b.status) return a.status === 'critical' ? -1 : b.status === 'critical' ? 1 : 0;
return b.purchaseKg - a.purchaseKg;
})
: activeTab === 'services' ? serviceNeeds : mappingNeeds;
: serviceNeeds;
const normalizedSearch = normalizeSearch(search);
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)
@@ -2422,9 +2358,8 @@ const PurchaseNeedsScreen = () => {
const paginatedNeeds = visibleNeeds.slice(startIndex, startIndex + itemsPerPage);
const purchaseItemCount = purchaseNeeds.length;
const suggestedPurchaseSummary = summarizePurchaseNeeds(purchaseNeeds);
const impactedSkuCount = countImpactedSkus(mappingNeeds);
const pageTitle = 'Necessidade de Compra';
const pageSubtitle = 'Materiais, serviços terceirizados e pendências de cadastro em filas separadas.';
const pageSubtitle = 'Materiais e serviços terceirizados para execução; pendências de cadastro ficam em Dados para revisar.';
const materialFilters: Array<{ id: MaterialPurchaseFilter; label: string; description: string; count: number }> = [
{ id: 'to_buy', label: 'Comprar agora', description: 'Materiais com saldo insuficiente para a demanda.', count: purchaseItemCount },
{ id: 'negative_balance', label: 'Saldo negativo', description: 'Divergências de estoque que precisam de conferência.', count: negativeBalanceNeeds.length },
@@ -2432,17 +2367,15 @@ const PurchaseNeedsScreen = () => {
{ id: 'all', label: 'Todos os materiais', description: 'Inclui itens cobertos para auditoria da conta.', count: materialNeeds.length },
];
const activeMaterialFilter = materialFilters.find(filter => filter.id === materialFilter);
const panelTitle = activeTab === 'materials' ? activeMaterialFilter?.label || 'Compra de materiais' : activeTab === 'services' ? 'Serviços terceirizados' : 'Pendências de composição';
const panelTitle = activeTab === 'materials' ? activeMaterialFilter?.label || 'Compra de materiais' : 'Serviços terceirizados';
const panelSubtitle = activeTab === 'materials'
? activeMaterialFilter?.description || '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.';
: 'Necessidades de beneficiamento e serviços. Não entram no total de compra de materiais.';
return (
<div className={pageClassName}>
<Header title={pageTitle} subtitle={pageSubtitle} backTo="/supplies" />
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<button
type="button"
onClick={() => openQueue('materials', 'to_buy')}
@@ -2466,15 +2399,6 @@ const PurchaseNeedsScreen = () => {
<p className="mt-2 text-3xl font-bold text-red-300">{negativeBalanceNeeds.length}</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">materiais com estoque negativo</p>
</button>
<button
type="button"
onClick={() => openQueue('mapping')}
className={`cursor-pointer rounded-2xl border p-5 text-left shadow-sm transition-colors ${activeTab === 'mapping' ? 'border-sky-400/45 bg-sky-400/[0.07]' : 'border-dark-border bg-dark-card hover:border-sky-400/35'}`}
>
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Dados bloqueando o plano</p>
<p className="mt-2 text-3xl font-bold text-sky-300">{impactedSkuCount || mappingNeeds.length}</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">SKUs sem consumo mapeado</p>
</button>
</div>
<div className={`${panelClassName} p-5`}>
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
@@ -2488,7 +2412,6 @@ const PurchaseNeedsScreen = () => {
type="button"
onClick={() => exportCsv(`necessidade-${activeTab}.csv`, visibleNeeds.map(need => ({
material: getNeedDisplayName(need),
pendencia: need.missingReference ? getNeedPendingLabel(need) : '',
planejado: need.plannedKg,
estoque: need.stockKg,
pendente: need.pendingKg,
@@ -2511,7 +2434,6 @@ const PurchaseNeedsScreen = () => {
{[
{ id: 'materials' as const, label: `Compra (${purchaseItemCount})` },
{ 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={() => openQueue(tab.id)} 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}
@@ -2527,7 +2449,7 @@ const PurchaseNeedsScreen = () => {
setCurrentPage(1);
}}
className={`${inputClassName} pl-9`}
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...'}
placeholder={activeTab === 'services' ? 'Buscar por serviço ou SKU impactado...' : 'Buscar por material, fornecedor ou cor...'}
/>
</label>
</div>
@@ -2556,8 +2478,6 @@ const PurchaseNeedsScreen = () => {
<div className="overflow-x-auto">
{activeTab === 'materials' ? (
<PurchaseMaterialQueue needs={paginatedNeeds} />
) : activeTab === 'mapping' ? (
<PurchaseMappingQueue needs={paginatedNeeds} />
) : (
<PurchaseServiceQueue needs={paginatedNeeds} />
)}
@@ -2568,7 +2488,7 @@ const PurchaseNeedsScreen = () => {
totalPages={totalPages}
pageSize={itemsPerPage}
pageSizeOptions={[10, 20, 50, 100]}
itemLabel={activeTab === 'materials' ? 'materiais' : activeTab === 'services' ? 'serviços' : 'pendências'}
itemLabel={activeTab === 'materials' ? 'materiais' : 'serviços'}
pageSizeLabel="itens por página"
startIndex={startIndex}
endIndex={Math.min(startIndex + itemsPerPage, visibleNeeds.length)}