Compare commits
2 Commits
3f8e5eeb0c
...
40737faaa5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40737faaa5 | ||
|
|
10eda267fa |
@@ -9,6 +9,7 @@ import {
|
|||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
Download,
|
Download,
|
||||||
|
Eye,
|
||||||
Link as LinkIcon,
|
Link as LinkIcon,
|
||||||
Package,
|
Package,
|
||||||
PackageSearch,
|
PackageSearch,
|
||||||
@@ -2083,42 +2084,61 @@ const formatNeedQuantity = (need: SupplyPurchaseNeed, value: number) => (
|
|||||||
`${formatNumber(value)} ${getNeedUnit(need)}`
|
`${formatNumber(value)} ${getNeedUnit(need)}`
|
||||||
);
|
);
|
||||||
|
|
||||||
const summarizePurchaseNeeds = (needs: SupplyPurchaseNeed[]) => {
|
|
||||||
const totalsByUnit = needs.reduce<Record<string, number>>((totals, need) => {
|
|
||||||
if (need.purchaseKg <= 0) return totals;
|
|
||||||
const unit = getNeedUnit(need);
|
|
||||||
totals[unit] = (totals[unit] || 0) + need.purchaseKg;
|
|
||||||
return totals;
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
const summaries = Object.entries(totalsByUnit).map(([unit, total]) => `${formatNumber(total)} ${unit}`);
|
|
||||||
return summaries.length ? summaries.join(' + ') : '0 kg';
|
|
||||||
};
|
|
||||||
|
|
||||||
const getNeedDisplayName = (need: SupplyPurchaseNeed) => (
|
const getNeedDisplayName = (need: SupplyPurchaseNeed) => (
|
||||||
need.material.replace(/^Cadastrar (consumo|rendimento):\s*/i, '')
|
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;
|
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';
|
type MaterialPurchaseFilter = 'to_buy' | 'negative_balance' | 'no_stock' | 'all';
|
||||||
|
type ServicePurchaseFilter = 'to_schedule' | 'covered' | 'all';
|
||||||
|
type PurchaseNeedFilter = MaterialPurchaseFilter | ServicePurchaseFilter;
|
||||||
|
|
||||||
const sumNeedProducts = (
|
const sumNeedProducts = (
|
||||||
need: SupplyPurchaseNeed,
|
need: SupplyPurchaseNeed,
|
||||||
field: 'suggestedQuantity' | 'quantitySold' | 'stockQuantity'
|
field: 'suggestedQuantity'
|
||||||
) => (
|
) => (
|
||||||
(need.products || []).reduce((total, product) => total + Number(product[field] || 0), 0)
|
(need.products || []).reduce((total, product) => total + Number(product[field] || 0), 0)
|
||||||
);
|
);
|
||||||
|
|
||||||
const countImpactedSkus = (needs: SupplyPurchaseNeed[]) => (
|
const AffectedProducts = ({ products }: { products: SupplyPurchaseNeed['products'] }) => {
|
||||||
new Set(needs.flatMap(need => (need.products || []).map(product => product.productId).filter(Boolean))).size
|
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[] }) => (
|
const PurchaseMaterialQueue = ({ needs }: { needs: SupplyPurchaseNeed[] }) => (
|
||||||
<>
|
<>
|
||||||
@@ -2192,22 +2212,7 @@ const PurchaseMaterialQueue = ({ needs }: { needs: SupplyPurchaseNeed[] }) => (
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{products.length > 0 && (
|
<AffectedProducts products={products} />
|
||||||
<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>
|
</article>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -2264,85 +2269,7 @@ const PurchaseServiceQueue = ({ needs }: { needs: SupplyPurchaseNeed[] }) => (
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{products.length > 0 && (
|
<AffectedProducts products={products} />
|
||||||
<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>
|
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -2357,8 +2284,9 @@ const PurchaseNeedsScreen = () => {
|
|||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const requestedTab = searchParams.get('tab');
|
const requestedTab = searchParams.get('tab');
|
||||||
const requestedFilter = searchParams.get('filter');
|
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 materialFilter: MaterialPurchaseFilter = requestedFilter === 'negative_balance' || requestedFilter === 'no_stock' || requestedFilter === 'all' ? requestedFilter : 'to_buy';
|
||||||
|
const serviceFilter: ServicePurchaseFilter = requestedFilter === 'covered' || requestedFilter === 'all' ? requestedFilter : 'to_schedule';
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [itemsPerPage, setItemsPerPage] = useState(10);
|
const [itemsPerPage, setItemsPerPage] = useState(10);
|
||||||
|
|
||||||
@@ -2387,15 +2315,15 @@ const PurchaseNeedsScreen = () => {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const openQueue = (tab: PurchaseNeedTab, filter: MaterialPurchaseFilter = 'to_buy') => {
|
const openQueue = (tab: PurchaseNeedTab, filter?: PurchaseNeedFilter) => {
|
||||||
|
const nextFilter = filter || (tab === 'materials' ? 'to_buy' : 'to_schedule');
|
||||||
setSearch('');
|
setSearch('');
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
setSearchParams(tab === 'materials' ? { tab, filter } : { tab });
|
setSearchParams({ tab, filter: nextFilter });
|
||||||
};
|
};
|
||||||
|
|
||||||
const materialNeeds = summary.purchaseNeeds.filter(need => !need.missingReference && !isServicePurchaseNeed(need));
|
const materialNeeds = summary.purchaseNeeds.filter(need => !need.missingReference && !isServicePurchaseNeed(need));
|
||||||
const serviceNeeds = 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 purchaseNeeds = materialNeeds.filter(need => need.purchaseKg > 0);
|
||||||
const negativeBalanceNeeds = materialNeeds.filter(hasNegativeMaterialBalance);
|
const negativeBalanceNeeds = materialNeeds.filter(hasNegativeMaterialBalance);
|
||||||
const noStockNeeds = materialNeeds.filter(need => need.stockKg === 0 && need.purchaseKg > 0);
|
const noStockNeeds = materialNeeds.filter(need => need.stockKg === 0 && need.purchaseKg > 0);
|
||||||
@@ -2405,13 +2333,18 @@ const PurchaseNeedsScreen = () => {
|
|||||||
if (materialFilter === 'no_stock') return need.stockKg === 0 && need.purchaseKg > 0;
|
if (materialFilter === 'no_stock') return need.stockKg === 0 && need.purchaseKg > 0;
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
const filteredServiceNeeds = serviceNeeds.filter(need => {
|
||||||
|
if (serviceFilter === 'to_schedule') return need.purchaseKg > 0;
|
||||||
|
if (serviceFilter === 'covered') return need.purchaseKg <= 0;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
const activeNeeds = activeTab === 'materials'
|
const activeNeeds = activeTab === 'materials'
|
||||||
? [...filteredMaterialNeeds].sort((a, b) => {
|
? [...filteredMaterialNeeds].sort((a, b) => {
|
||||||
if (hasNegativeMaterialBalance(a) !== hasNegativeMaterialBalance(b)) return hasNegativeMaterialBalance(a) ? -1 : 1;
|
if (hasNegativeMaterialBalance(a) !== hasNegativeMaterialBalance(b)) return hasNegativeMaterialBalance(a) ? -1 : 1;
|
||||||
if (a.status !== b.status) return a.status === 'critical' ? -1 : b.status === 'critical' ? 1 : 0;
|
if (a.status !== b.status) return a.status === 'critical' ? -1 : b.status === 'critical' ? 1 : 0;
|
||||||
return b.purchaseKg - a.purchaseKg;
|
return b.purchaseKg - a.purchaseKg;
|
||||||
})
|
})
|
||||||
: activeTab === 'services' ? serviceNeeds : mappingNeeds;
|
: filteredServiceNeeds;
|
||||||
const normalizedSearch = normalizeSearch(search);
|
const normalizedSearch = normalizeSearch(search);
|
||||||
const visibleNeeds = activeNeeds.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)
|
||||||
@@ -2421,146 +2354,90 @@ const PurchaseNeedsScreen = () => {
|
|||||||
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 = purchaseNeeds.length;
|
const purchaseItemCount = purchaseNeeds.length;
|
||||||
const suggestedPurchaseSummary = summarizePurchaseNeeds(purchaseNeeds);
|
const materialFilters: Array<{ id: MaterialPurchaseFilter; label: string; count: number }> = [
|
||||||
const impactedSkuCount = countImpactedSkus(mappingNeeds);
|
{ id: 'to_buy', label: 'Comprar agora', count: purchaseItemCount },
|
||||||
const pageTitle = 'Necessidade de Compra';
|
{ id: 'negative_balance', label: 'Saldo negativo', count: negativeBalanceNeeds.length },
|
||||||
const pageSubtitle = 'Materiais, serviços terceirizados e pendências de cadastro em filas separadas.';
|
{ id: 'no_stock', label: 'Sem saldo', count: noStockNeeds.length },
|
||||||
const materialFilters: Array<{ id: MaterialPurchaseFilter; label: string; description: string; count: number }> = [
|
{ id: 'all', label: 'Todos os materiais', count: materialNeeds.length },
|
||||||
{ 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 },
|
|
||||||
{ id: 'no_stock', label: 'Sem saldo', description: 'Materiais sem saldo disponível no Tiny.', count: noStockNeeds.length },
|
|
||||||
{ 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 serviceFilters: Array<{ id: ServicePurchaseFilter; label: string; count: number }> = [
|
||||||
const panelTitle = activeTab === 'materials' ? activeMaterialFilter?.label || 'Compra de materiais' : activeTab === 'services' ? 'Serviços terceirizados' : 'Pendências de composição';
|
{ id: 'to_schedule', label: 'A programar', count: serviceNeeds.filter(need => need.purchaseKg > 0).length },
|
||||||
const panelSubtitle = activeTab === 'materials'
|
{ id: 'covered', label: 'Sem programação', count: serviceNeeds.filter(need => need.purchaseKg <= 0).length },
|
||||||
? activeMaterialFilter?.description || 'Demanda de produto → material → saldo disponível → quantidade para comprar.'
|
{ id: 'all', label: 'Todos os serviços', count: serviceNeeds.length },
|
||||||
: activeTab === 'services'
|
];
|
||||||
? 'Necessidades de beneficiamento e serviços. Não entram no total de compra de materiais.'
|
const activeFilter = activeTab === 'materials' ? materialFilter : serviceFilter;
|
||||||
: 'SKUs sem consumo confiável. Corrija o cadastro antes de usar estes valores para comprar.';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={pageClassName}>
|
<div className={pageClassName}>
|
||||||
<Header title={pageTitle} subtitle={pageSubtitle} backTo="/supplies" />
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
<Header title="Necessidade de Compra" subtitle="Materiais e serviços terceirizados para execução; pendências de cadastro ficam em Dados para revisar." backTo="/supplies" />
|
||||||
<button
|
<div className="flex flex-wrap gap-2">
|
||||||
type="button"
|
<button type="button" onClick={loadSummary} className={buttonClassName}><RefreshCw className="h-4 w-4" /> Atualizar</button>
|
||||||
onClick={() => openQueue('materials', 'to_buy')}
|
<button
|
||||||
className={`cursor-pointer rounded-2xl border p-5 text-left shadow-sm transition-colors ${activeTab === 'materials' && materialFilter === 'to_buy' ? 'border-brand-primary/45 bg-brand-primary/[0.07]' : 'border-dark-border bg-dark-card hover:border-brand-primary/35'}`}
|
type="button"
|
||||||
>
|
onClick={() => exportCsv(`necessidade-${activeTab}.csv`, visibleNeeds.map(need => ({
|
||||||
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Materiais para comprar</p>
|
material: getNeedDisplayName(need),
|
||||||
<p className="mt-2 text-3xl font-bold text-dark-text">{purchaseItemCount}</p>
|
planejado: need.plannedKg,
|
||||||
<p className="mt-1 text-xs font-semibold text-dark-muted">itens com falta real</p>
|
estoque: need.stockKg,
|
||||||
</button>
|
pendente: need.pendingKg,
|
||||||
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
|
comprar: need.purchaseKg,
|
||||||
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Compra sugerida</p>
|
unidade: getNeedUnit(need),
|
||||||
<p className="mt-2 text-2xl font-bold text-brand-primary">{suggestedPurchaseSummary}</p>
|
prioridade: need.priority,
|
||||||
<p className="mt-1 text-xs font-semibold text-dark-muted">necessidade após saldo e recebimentos</p>
|
cobertura: purchaseStatusLabels[need.status],
|
||||||
|
fornecedores: need.suppliers.join(' | '),
|
||||||
|
cores: need.colors.join(' | '),
|
||||||
|
produtos: (need.products || []).map(product => `${product.productId} ${product.name}`).join(' | '),
|
||||||
|
})))}
|
||||||
|
className={buttonClassName}
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" /> Exportar CSV
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => openQueue('materials', 'negative_balance')}
|
|
||||||
className={`cursor-pointer rounded-2xl border p-5 text-left shadow-sm transition-colors ${activeTab === 'materials' && materialFilter === 'negative_balance' ? 'border-red-400/45 bg-red-400/[0.07]' : 'border-dark-border bg-dark-card hover:border-red-400/35'}`}
|
|
||||||
>
|
|
||||||
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Saldos a conferir</p>
|
|
||||||
<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>
|
||||||
<div className={`${panelClassName} p-5`}>
|
<div className="grid grid-cols-1 gap-3 rounded-2xl border border-dark-border bg-dark-card p-4 shadow-sm xl:grid-cols-[auto_minmax(0,1fr)_190px]">
|
||||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
<div className="inline-flex w-fit rounded-xl border border-dark-border bg-dark-input p-1">
|
||||||
<div>
|
{[
|
||||||
<h2 className="text-base font-bold text-dark-text">{panelTitle}</h2>
|
{ id: 'materials' as const, label: 'Materiais' },
|
||||||
<p className="mt-1 text-sm font-semibold text-dark-muted">{panelSubtitle}</p>
|
{ id: 'services' as const, label: 'Serviços' },
|
||||||
</div>
|
].map(tab => (
|
||||||
<div className="flex flex-wrap gap-2">
|
<button key={tab.id} type="button" onClick={() => openQueue(tab.id)} className={`rounded-lg px-4 py-2 text-sm font-bold transition-colors cursor-pointer ${activeTab === tab.id ? 'bg-brand-primary text-brand-contrast' : 'text-dark-muted hover:bg-dark-card hover:text-dark-text'}`}>
|
||||||
<button type="button" onClick={loadSummary} className={buttonClassName}><RefreshCw className="h-4 w-4" /> Atualizar</button>
|
{tab.label}
|
||||||
<button
|
|
||||||
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,
|
|
||||||
comprar: need.purchaseKg,
|
|
||||||
unidade: getNeedUnit(need),
|
|
||||||
prioridade: need.priority,
|
|
||||||
cobertura: need.missingReference ? 'Sem referência de consumo' : purchaseStatusLabels[need.status],
|
|
||||||
fornecedores: need.suppliers.join(' | '),
|
|
||||||
cores: need.colors.join(' | '),
|
|
||||||
produtos: (need.products || []).map(product => `${product.productId} ${product.name}`).join(' | '),
|
|
||||||
})))}
|
|
||||||
className={buttonClassName}
|
|
||||||
>
|
|
||||||
<Download className="h-4 w-4" /> Exportar CSV
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
|
<label className="relative block">
|
||||||
<div className="inline-flex w-fit rounded-xl border border-dark-border bg-dark-input p-1">
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-dark-muted" />
|
||||||
{[
|
<input
|
||||||
{ id: 'materials' as const, label: `Compra (${purchaseItemCount})` },
|
value={search}
|
||||||
{ id: 'services' as const, label: `Serviços (${serviceNeeds.length})` },
|
onChange={(event) => {
|
||||||
{ id: 'mapping' as const, label: `Mapear dados (${mappingNeeds.length})` },
|
setSearch(event.target.value);
|
||||||
].map(tab => (
|
setCurrentPage(1);
|
||||||
<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}
|
className="h-11 w-full rounded-xl border border-dark-border bg-dark-input py-2.5 pl-10 pr-4 text-sm font-semibold text-dark-text outline-none transition-colors placeholder:text-dark-muted hover:border-brand-primary focus:border-brand-primary"
|
||||||
</button>
|
placeholder={activeTab === 'services' ? 'Buscar por serviço ou SKU impactado...' : 'Buscar por material, fornecedor ou cor...'}
|
||||||
))}
|
/>
|
||||||
</div>
|
</label>
|
||||||
<label className="relative block w-full xl:max-w-md">
|
<select
|
||||||
<Search className="pointer-events-none absolute left-3 top-3 h-4 w-4 text-dark-muted" />
|
value={activeFilter}
|
||||||
<input
|
onChange={(event) => openQueue(activeTab, event.target.value as PurchaseNeedFilter)}
|
||||||
value={search}
|
className="h-11 rounded-xl border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none transition-colors focus:border-brand-primary cursor-pointer"
|
||||||
onChange={(event) => {
|
aria-label={activeTab === 'materials' ? 'Filtro de materiais' : 'Filtro de serviços'}
|
||||||
setSearch(event.target.value);
|
>
|
||||||
setCurrentPage(1);
|
{activeTab === 'materials'
|
||||||
}}
|
? materialFilters.map(filter => <option key={filter.id} value={filter.id}>{filter.label} ({filter.count})</option>)
|
||||||
className={`${inputClassName} pl-9`}
|
: serviceFilters.map(filter => <option key={filter.id} value={filter.id}>{filter.label} ({filter.count})</option>)}
|
||||||
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...'}
|
</select>
|
||||||
/>
|
</div>
|
||||||
</label>
|
<div className={panelClassName}>
|
||||||
</div>
|
|
||||||
{activeTab === 'materials' && (
|
|
||||||
<div className="mt-4 flex flex-wrap gap-2">
|
|
||||||
{materialFilters.map(filter => (
|
|
||||||
<button
|
|
||||||
key={filter.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => openQueue('materials', filter.id)}
|
|
||||||
className={`cursor-pointer rounded-lg border px-3 py-2 text-left text-xs font-bold transition-colors ${materialFilter === filter.id ? 'border-brand-primary/40 bg-brand-primary/10 text-brand-primary' : 'border-dark-border bg-dark-input text-dark-muted hover:border-brand-primary/35 hover:text-dark-text'}`}
|
|
||||||
title={filter.description}
|
|
||||||
>
|
|
||||||
{filter.label} <span className="ml-1 opacity-75">{filter.count}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className={emptyStateClassName}>
|
<div className={`${emptyStateClassName} min-h-[280px]`}>
|
||||||
<BarChart3 className="h-8 w-8 text-brand-primary" />
|
<BarChart3 className="h-8 w-8 text-brand-primary" />
|
||||||
<h3 className="text-base font-bold text-dark-text">Calculando necessidade...</h3>
|
<h3 className="text-base font-bold text-dark-text">Calculando necessidade...</h3>
|
||||||
</div>
|
</div>
|
||||||
) : visibleNeeds.length ? (
|
) : visibleNeeds.length ? (
|
||||||
<div className="mt-4 overflow-hidden rounded-xl border border-dark-border">
|
<>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
{activeTab === 'materials' ? (
|
{activeTab === 'materials' ? <PurchaseMaterialQueue needs={paginatedNeeds} /> : <PurchaseServiceQueue needs={paginatedNeeds} />}
|
||||||
<PurchaseMaterialQueue needs={paginatedNeeds} />
|
|
||||||
) : activeTab === 'mapping' ? (
|
|
||||||
<PurchaseMappingQueue needs={paginatedNeeds} />
|
|
||||||
) : (
|
|
||||||
<PurchaseServiceQueue needs={paginatedNeeds} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<PaginationControls
|
<PaginationControls
|
||||||
totalItems={visibleNeeds.length}
|
totalItems={visibleNeeds.length}
|
||||||
@@ -2568,7 +2445,7 @@ const PurchaseNeedsScreen = () => {
|
|||||||
totalPages={totalPages}
|
totalPages={totalPages}
|
||||||
pageSize={itemsPerPage}
|
pageSize={itemsPerPage}
|
||||||
pageSizeOptions={[10, 20, 50, 100]}
|
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"
|
pageSizeLabel="itens por página"
|
||||||
startIndex={startIndex}
|
startIndex={startIndex}
|
||||||
endIndex={Math.min(startIndex + itemsPerPage, visibleNeeds.length)}
|
endIndex={Math.min(startIndex + itemsPerPage, visibleNeeds.length)}
|
||||||
@@ -2579,12 +2456,12 @@ const PurchaseNeedsScreen = () => {
|
|||||||
}}
|
}}
|
||||||
className="border-t border-dark-border px-4 py-3"
|
className="border-t border-dark-border px-4 py-3"
|
||||||
/>
|
/>
|
||||||
</div>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className={emptyStateClassName}>
|
<div className={`${emptyStateClassName} min-h-[280px]`}>
|
||||||
<BarChart3 className="h-8 w-8 text-brand-primary" />
|
<BarChart3 className="h-8 w-8 text-brand-primary" />
|
||||||
<h3 className="text-base font-bold text-dark-text">{summary.purchaseNeeds.length ? 'Nenhuma necessidade encontrada' : 'Nenhuma necessidade de compra'}</h3>
|
<h3 className="text-base font-bold text-dark-text">{activeTab === 'services' ? 'Nenhum serviço encontrado' : 'Nenhuma necessidade encontrada'}</h3>
|
||||||
<p className="text-sm font-semibold text-dark-muted">{summary.purchaseNeeds.length ? 'Ajuste a busca.' : 'Cadastre planos de malha para gerar demanda e aprove recebimentos para abater estoque.'}</p>
|
<p className="text-sm font-semibold text-dark-muted">{summary.purchaseNeeds.length ? 'Ajuste a busca ou o filtro.' : 'Cadastre planos de malha para gerar demanda e aprove recebimentos para abater estoque.'}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user