Unify purchase need controls
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m23s

This commit is contained in:
Cauê Faleiros
2026-08-10 09:58:58 -03:00
parent 10eda267fa
commit 40737faaa5

View File

@@ -2084,18 +2084,6 @@ 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, '')
); );
@@ -2104,6 +2092,8 @@ const hasNegativeMaterialBalance = (need: SupplyPurchaseNeed) => need.stockKg <
type PurchaseNeedTab = 'materials' | 'services'; 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,
@@ -2296,6 +2286,7 @@ const PurchaseNeedsScreen = () => {
const requestedFilter = searchParams.get('filter'); const requestedFilter = searchParams.get('filter');
const activeTab: PurchaseNeedTab = requestedTab === 'services' ? 'services' : '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);
@@ -2324,10 +2315,11 @@ 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));
@@ -2341,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;
}) })
: serviceNeeds; : 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)
@@ -2357,130 +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 pageTitle = 'Necessidade de Compra'; { id: 'to_buy', label: 'Comprar agora', count: purchaseItemCount },
const pageSubtitle = 'Materiais e serviços terceirizados para execução; pendências de cadastro ficam em Dados para revisar.'; { id: 'negative_balance', label: 'Saldo negativo', count: negativeBalanceNeeds.length },
const materialFilters: Array<{ id: MaterialPurchaseFilter; label: string; description: string; count: number }> = [ { id: 'no_stock', label: 'Sem saldo', count: noStockNeeds.length },
{ id: 'to_buy', label: 'Comprar agora', description: 'Materiais com saldo insuficiente para a demanda.', count: purchaseItemCount }, { id: 'all', label: 'Todos os materiais', count: materialNeeds.length },
{ 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' : 'Serviços terceirizados'; { 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 },
: 'Necessidades de beneficiamento e serviços. Não entram no total de compra de materiais.'; ];
const activeFilter = activeTab === 'materials' ? materialFilter : serviceFilter;
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-3"> <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>
</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),
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) => {
].map(tab => ( setSearch(event.target.value);
<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'}`}> setCurrentPage(1);
{tab.label} }}
</button> 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"
))} placeholder={activeTab === 'services' ? 'Buscar por serviço ou SKU impactado...' : 'Buscar por material, fornecedor ou cor...'}
</div> />
<label className="relative block w-full xl:max-w-md"> </label>
<Search className="pointer-events-none absolute left-3 top-3 h-4 w-4 text-dark-muted" /> <select
<input value={activeFilter}
value={search} onChange={(event) => openQueue(activeTab, event.target.value as PurchaseNeedFilter)}
onChange={(event) => { 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"
setSearch(event.target.value); aria-label={activeTab === 'materials' ? 'Filtro de materiais' : 'Filtro de serviços'}
setCurrentPage(1); >
}} {activeTab === 'materials'
className={`${inputClassName} pl-9`} ? materialFilters.map(filter => <option key={filter.id} value={filter.id}>{filter.label} ({filter.count})</option>)
placeholder={activeTab === 'services' ? 'Buscar por serviço ou SKU impactado...' : 'Buscar por material, fornecedor ou cor...'} : serviceFilters.map(filter => <option key={filter.id} value={filter.id}>{filter.label} ({filter.count})</option>)}
/> </select>
</label> </div>
</div> <div className={panelClassName}>
{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} />
) : (
<PurchaseServiceQueue needs={paginatedNeeds} />
)}
</div> </div>
<PaginationControls <PaginationControls
totalItems={visibleNeeds.length} totalItems={visibleNeeds.length}
@@ -2499,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>