Refine purchase needs setup queue

This commit is contained in:
Cauê Faleiros
2026-07-21 13:56:10 -03:00
parent 51b73ed82b
commit b4e51eb8eb

View File

@@ -1781,6 +1781,26 @@ const summarizePurchaseNeeds = (needs: SupplyPurchaseNeed[]) => {
return summaries.length ? summaries.join(' + ') : '0 kg'; return summaries.length ? summaries.join(' + ') : '0 kg';
}; };
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 sumNeedProducts = (
need: SupplyPurchaseNeed,
field: 'suggestedQuantity' | 'quantitySold' | 'stockQuantity'
) => (
(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 PurchaseNeedsScreen = () => { const PurchaseNeedsScreen = () => {
const [summary, setSummary] = useState<SupplySummary>(emptySupplySummary); const [summary, setSummary] = useState<SupplySummary>(emptySupplySummary);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
@@ -1815,7 +1835,7 @@ const PurchaseNeedsScreen = () => {
const normalizedSearch = normalizeSearch(search); const normalizedSearch = normalizeSearch(search);
const visibleNeeds = summary.purchaseNeeds.filter(need => ( const visibleNeeds = summary.purchaseNeeds.filter(need => (
!normalizedSearch || normalizeSearch(`${need.material} ${need.suppliers.join(' ')} ${need.colors.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);
@@ -1825,17 +1845,36 @@ const PurchaseNeedsScreen = () => {
const suggestedPurchaseSummary = summarizePurchaseNeeds(summary.purchaseNeeds); const suggestedPurchaseSummary = summarizePurchaseNeeds(summary.purchaseNeeds);
const pendingSupplierCount = new Set(summary.purchaseNeeds.flatMap(need => need.suppliers)).size; const pendingSupplierCount = new Set(summary.purchaseNeeds.flatMap(need => need.suppliers)).size;
const missingReferenceCount = summary.purchaseNeeds.filter(need => need.missingReference).length; 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 = setupMode ? 'Pendências para Compra' : '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 ? 'Fila de cadastro para compra' : '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}` },
];
return ( return (
<div className={pageClassName}> <div className={pageClassName}>
<Header title="Necessidade de Compra" subtitle="Materiais abaixo do mínimo e necessidade projetada para compra." backTo="/supplies" /> <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-4">
{[ {stats.map(stat => (
{ label: 'Itens a comprar', value: `${purchaseItemCount}` },
{ label: 'Compra sugerida', value: suggestedPurchaseSummary },
{ label: 'Fornecedores', value: `${pendingSupplierCount}` },
{ label: 'Sem referência', value: `${missingReferenceCount}` },
].map(stat => (
<div key={stat.label} className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm"> <div key={stat.label} className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">{stat.label}</p> <p className="text-xs font-bold uppercase tracking-widest text-dark-muted">{stat.label}</p>
<p className="mt-2 text-3xl font-bold text-dark-text">{stat.value}</p> <p className="mt-2 text-3xl font-bold text-dark-text">{stat.value}</p>
@@ -1845,19 +1884,20 @@ const PurchaseNeedsScreen = () => {
<div className={`${panelClassName} p-5`}> <div className={`${panelClassName} p-5`}>
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between"> <div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div> <div>
<h2 className="text-base font-bold text-dark-text">Necessidade por material</h2> <h2 className="text-base font-bold text-dark-text">{panelTitle}</h2>
<p className="mt-1 text-sm font-semibold text-dark-muted">Planejado - estoque aprovado - recebimentos pendentes.</p> <p className="mt-1 text-sm font-semibold text-dark-muted">{panelSubtitle}</p>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<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('necessidade-compra.csv', visibleNeeds.map(need => ({ onClick={() => exportCsv(setupMode ? 'pendencias-compra.csv' : 'necessidade-compra.csv', visibleNeeds.map(need => ({
material: need.material, material: getNeedDisplayName(need),
planejado_kg: need.plannedKg, pendencia: need.missingReference ? getNeedPendingLabel(need) : '',
estoque_kg: need.stockKg, planejado: need.plannedKg,
pendente_kg: need.pendingKg, estoque: need.stockKg,
comprar_kg: need.purchaseKg, pendente: need.pendingKg,
comprar: need.purchaseKg,
unidade: getNeedUnit(need), unidade: getNeedUnit(need),
prioridade: need.priority, prioridade: need.priority,
cobertura: need.missingReference ? 'Sem referência de consumo' : purchaseStatusLabels[need.status], cobertura: need.missingReference ? 'Sem referência de consumo' : purchaseStatusLabels[need.status],
@@ -1871,6 +1911,11 @@ const PurchaseNeedsScreen = () => {
</button> </button>
</div> </div>
</div> </div>
{setupMode && !isLoading && summary.purchaseNeeds.length > 0 && (
<div className="mt-4 rounded-xl border border-amber-400/20 bg-amber-400/10 px-4 py-3 text-sm font-semibold text-amber-100">
Esta fila ainda não é uma lista final de compra. Os valores mostram impacto estimado por SKU até que as referências de consumo sejam cadastradas.
</div>
)}
<label className="relative mt-4 block"> <label className="relative mt-4 block">
<Search className="pointer-events-none absolute left-3 top-3 h-4 w-4 text-dark-muted" /> <Search className="pointer-events-none absolute left-3 top-3 h-4 w-4 text-dark-muted" />
<input <input
@@ -1880,7 +1925,7 @@ const PurchaseNeedsScreen = () => {
setCurrentPage(1); setCurrentPage(1);
}} }}
className={`${inputClassName} pl-9`} className={`${inputClassName} pl-9`}
placeholder="Buscar por material, fornecedor ou cor..." placeholder={setupMode ? 'Buscar por SKU, produto ou pendência...' : 'Buscar por material, fornecedor ou cor...'}
/> />
</label> </label>
{isLoading ? ( {isLoading ? (
@@ -1891,80 +1936,149 @@ 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">
<table className="w-full min-w-[920px] table-fixed border-collapse"> {setupMode ? (
<colgroup> <table className="w-full min-w-[980px] table-fixed border-collapse">
<col /> <colgroup>
<col className="w-[120px]" /> <col />
<col className="w-[120px]" /> <col className="w-[120px]" />
<col className="w-[120px]" /> <col className="w-[130px]" />
<col className="w-[120px]" /> <col className="w-[150px]" />
<col className="w-[150px]" /> <col className="w-[150px]" />
</colgroup> <col className="w-[140px]" />
<thead className="border-b border-dark-border bg-dark-input/40 text-xs font-bold uppercase tracking-widest text-dark-muted"> </colgroup>
<tr> <thead className="border-b border-dark-border bg-dark-input/40 text-xs font-bold uppercase tracking-widest text-dark-muted">
<th scope="col" className="px-4 py-3 text-left">Material</th> <tr>
<th scope="col" className="px-4 py-3 text-right">Planejado</th> <th scope="col" className="px-4 py-3 text-left">Produto</th>
<th scope="col" className="px-4 py-3 text-right">Estoque</th> <th scope="col" className="px-4 py-3 text-right">Vendido</th>
<th scope="col" className="px-4 py-3 text-right">Pendente</th> <th scope="col" className="px-4 py-3 text-right">Estoque produto</th>
<th scope="col" className="px-4 py-3 text-right">Comprar</th> <th scope="col" className="px-4 py-3 text-right">Impacto estimado</th>
<th scope="col" className="px-4 py-3 text-left">Cobertura</th> <th scope="col" className="px-4 py-3 text-left">Pendência</th>
</tr> <th scope="col" className="px-4 py-3 text-right">Ação</th>
</thead> </tr>
<tbody className="divide-y divide-dark-border bg-dark-card"> </thead>
{paginatedNeeds.map(need => { <tbody className="divide-y divide-dark-border bg-dark-card">
const referenceProduct = need.products?.[0]; {paginatedNeeds.map(need => {
return ( const referenceProduct = need.products?.[0];
<tr key={need.material}> const productName = referenceProduct?.name || getNeedDisplayName(need);
<td className="px-4 py-4 align-middle"> const productId = referenceProduct?.productId || '';
<div className="min-w-0"> const soldQuantity = referenceProduct?.quantitySold ?? sumNeedProducts(need, 'quantitySold');
<p className="text-sm font-bold text-dark-text">{need.material}</p> const stockQuantity = referenceProduct?.stockQuantity ?? sumNeedProducts(need, 'stockQuantity');
<div className="mt-1 flex flex-wrap items-center gap-2">
<p className="text-xs font-semibold text-dark-muted"> return (
{need.missingReference <tr key={need.material}>
? 'Cadastre produto/material em Cadastros > Referência de Consumo' <td className="px-4 py-4 align-middle">
: `${(need.colors.length ? need.colors.join(', ') : 'Todas as cores')} · ${(need.suppliers.length ? need.suppliers.join(', ') : 'Sem fornecedor')}`} <div className="min-w-0">
</p> <p className="truncate text-sm font-bold text-dark-text">{productName}</p>
{need.missingReference && referenceProduct ? ( <p className="mt-1 text-xs font-semibold text-dark-muted">
<RouterLink {productId ? `SKU ${productId}` : 'SKU não informado'}
to={buildConsumptionReferencePath({ sku: referenceProduct.productId, name: referenceProduct.name })} </p>
className="inline-flex h-7 w-7 items-center justify-center rounded-lg bg-dark-input text-dark-text transition-colors hover:bg-dark-border" </div>
title={`Cadastrar referência do SKU ${referenceProduct.productId}`} </td>
aria-label={`Cadastrar referência do SKU ${referenceProduct.productId}`} <td className="px-4 py-4 text-right align-middle text-sm font-bold text-dark-text">{formatNumber(soldQuantity, 0)} un.</td>
> <td className="px-4 py-4 text-right align-middle text-sm font-bold text-dark-text">{formatNumber(stockQuantity, 0)} un.</td>
<Pencil className="h-3.5 w-3.5" /> <td className="px-4 py-4 text-right align-middle text-sm font-bold text-amber-300">{formatNeedQuantity(need, need.purchaseKg)}</td>
</RouterLink> <td className="px-4 py-4 align-middle">
) : null} <div className="flex flex-col gap-1">
</div> <span className="w-fit rounded-full border border-amber-400/30 bg-amber-400/10 px-2.5 py-1 text-xs font-bold text-amber-300">
{need.products?.length ? ( {getNeedPendingLabel(need)}
<p className="mt-1 text-xs font-semibold text-dark-muted"> </span>
{need.products.slice(0, 2).map(product => product.productId).join(', ')} <span className="text-xs font-semibold text-dark-muted">Referência de consumo incompleta</span>
{need.products.length > 2 ? ` +${need.products.length - 2}` : ''} </div>
</p> </td>
) : null} <td className="px-4 py-4 text-right align-middle">
</div> {referenceProduct ? (
</td> <RouterLink
<td className="px-4 py-4 text-right align-middle text-sm font-bold text-dark-text">{formatNeedQuantity(need, need.plannedKg)}</td> to={buildConsumptionReferencePath({ sku: referenceProduct.productId, name: referenceProduct.name })}
<td className="px-4 py-4 text-right align-middle text-sm font-bold text-dark-text">{formatNeedQuantity(need, need.stockKg)}</td> className="inline-flex h-9 items-center justify-center gap-2 rounded-lg bg-dark-input px-3 text-sm font-bold text-dark-text transition-colors hover:bg-dark-border"
<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 ${need.purchaseKg > 0 ? 'text-amber-300' : 'text-emerald-300'}`}>{formatNeedQuantity(need, need.purchaseKg)}</td> <Pencil className="h-4 w-4" />
<td className="px-4 py-4 align-middle"> Cadastrar
<span className={`inline-flex whitespace-nowrap rounded-full border px-2.5 py-1 text-xs font-bold ${ </RouterLink>
need.missingReference ) : (
? 'border-red-400/30 bg-red-400/10 text-red-300' <span className="text-xs font-semibold text-dark-muted">Sem SKU</span>
: need.status === 'critical' )}
? 'border-red-400/30 bg-red-400/10 text-red-300' </td>
: need.status === 'attention' </tr>
? 'border-amber-400/30 bg-amber-400/10 text-amber-300' );
: 'border-emerald-400/30 bg-emerald-400/10 text-emerald-300' })}
}`}> </tbody>
{need.missingReference ? 'Sem referência' : purchaseStatusLabels[need.status]} </table>
</span> ) : (
</td> <table className="w-full min-w-[920px] table-fixed border-collapse">
</tr> <colgroup>
); <col />
})} <col className="w-[120px]" />
</tbody> <col className="w-[120px]" />
</table> <col className="w-[120px]" />
<col className="w-[120px]" />
<col className="w-[150px]" />
</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">Material</th>
<th scope="col" className="px-4 py-3 text-right">Planejado</th>
<th scope="col" className="px-4 py-3 text-right">Estoque</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>
</tr>
</thead>
<tbody className="divide-y divide-dark-border bg-dark-card">
{paginatedNeeds.map(need => {
const referenceProduct = need.products?.[0];
return (
<tr key={need.material}>
<td className="px-4 py-4 align-middle">
<div className="min-w-0">
<p className="text-sm font-bold text-dark-text">{getNeedDisplayName(need)}</p>
<div className="mt-1 flex flex-wrap items-center gap-2">
<p className="text-xs font-semibold text-dark-muted">
{need.missingReference
? 'Cadastre produto/material em Cadastros > Referência de Consumo'
: `${(need.colors.length ? need.colors.join(', ') : 'Todas as cores')} · ${(need.suppliers.length ? need.suppliers.join(', ') : 'Sem fornecedor')}`}
</p>
{need.missingReference && referenceProduct ? (
<RouterLink
to={buildConsumptionReferencePath({ sku: referenceProduct.productId, name: referenceProduct.name })}
className="inline-flex h-7 w-7 items-center justify-center rounded-lg bg-dark-input text-dark-text transition-colors hover:bg-dark-border"
title={`Cadastrar referência do SKU ${referenceProduct.productId}`}
aria-label={`Cadastrar referência do SKU ${referenceProduct.productId}`}
>
<Pencil className="h-3.5 w-3.5" />
</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 ${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
? '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'
: need.status === 'attention'
? '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]}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div> </div>
<PaginationControls <PaginationControls
totalItems={visibleNeeds.length} totalItems={visibleNeeds.length}