Compare commits
5 Commits
51b73ed82b
...
a6e07e46d9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6e07e46d9 | ||
|
|
166f97c629 | ||
|
|
98d278947b | ||
|
|
c7536d9097 | ||
|
|
b4e51eb8eb |
@@ -1781,6 +1781,26 @@ const summarizePurchaseNeeds = (needs: SupplyPurchaseNeed[]) => {
|
||||
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 [summary, setSummary] = useState<SupplySummary>(emptySupplySummary);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@@ -1815,7 +1835,7 @@ const PurchaseNeedsScreen = () => {
|
||||
|
||||
const normalizedSearch = normalizeSearch(search);
|
||||
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 safeCurrentPage = Math.min(currentPage, totalPages || 1);
|
||||
@@ -1825,17 +1845,36 @@ const PurchaseNeedsScreen = () => {
|
||||
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 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}` },
|
||||
];
|
||||
|
||||
return (
|
||||
<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">
|
||||
{[
|
||||
{ label: 'Itens a comprar', value: `${purchaseItemCount}` },
|
||||
{ label: 'Compra sugerida', value: suggestedPurchaseSummary },
|
||||
{ label: 'Fornecedores', value: `${pendingSupplierCount}` },
|
||||
{ label: 'Sem referência', value: `${missingReferenceCount}` },
|
||||
].map(stat => (
|
||||
{stats.map(stat => (
|
||||
<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="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="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-dark-text">Necessidade por material</h2>
|
||||
<p className="mt-1 text-sm font-semibold text-dark-muted">Planejado - estoque aprovado - recebimentos pendentes.</p>
|
||||
<h2 className="text-base font-bold text-dark-text">{panelTitle}</h2>
|
||||
<p className="mt-1 text-sm font-semibold text-dark-muted">{panelSubtitle}</p>
|
||||
</div>
|
||||
<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={() => exportCsv('necessidade-compra.csv', visibleNeeds.map(need => ({
|
||||
material: need.material,
|
||||
planejado_kg: need.plannedKg,
|
||||
estoque_kg: need.stockKg,
|
||||
pendente_kg: need.pendingKg,
|
||||
comprar_kg: need.purchaseKg,
|
||||
onClick={() => exportCsv(setupMode ? 'pendencias-compra.csv' : 'necessidade-compra.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],
|
||||
@@ -1880,7 +1920,7 @@ const PurchaseNeedsScreen = () => {
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
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>
|
||||
{isLoading ? (
|
||||
@@ -1891,80 +1931,147 @@ const PurchaseNeedsScreen = () => {
|
||||
) : visibleNeeds.length ? (
|
||||
<div className="mt-4 overflow-hidden rounded-xl border border-dark-border">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[920px] table-fixed border-collapse">
|
||||
<colgroup>
|
||||
<col />
|
||||
<col className="w-[120px]" />
|
||||
<col className="w-[120px]" />
|
||||
<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">{need.material}</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>
|
||||
{setupMode ? (
|
||||
<table className="w-full min-w-[880px] table-fixed border-collapse">
|
||||
<colgroup>
|
||||
<col className="w-[34%]" />
|
||||
<col className="w-[12%]" />
|
||||
<col className="w-[14%]" />
|
||||
<col className="w-[15%]" />
|
||||
<col className="w-[15%]" />
|
||||
<col className="w-[10%]" />
|
||||
</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">Produto</th>
|
||||
<th scope="col" className="px-4 py-3 text-right">Vendido</th>
|
||||
<th scope="col" className="px-4 py-3 text-right">Estoque produto</th>
|
||||
<th scope="col" className="px-4 py-3 text-right">Impacto estimado</th>
|
||||
<th scope="col" className="px-4 py-3 text-right">Pendência</th>
|
||||
<th scope="col" className="px-4 py-3 text-right">Ação</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-dark-border bg-dark-card">
|
||||
{paginatedNeeds.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 (
|
||||
<tr key={need.material}>
|
||||
<td className="px-4 py-4 align-middle">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-bold text-dark-text">{productName}</p>
|
||||
<p className="mt-1 text-xs font-semibold text-dark-muted">
|
||||
{productId ? `SKU ${productId}` : 'SKU não informado'}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
<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>
|
||||
<td className="px-4 py-4 text-right align-middle text-sm font-bold text-amber-300">{formatNeedQuantity(need, need.purchaseKg)}</td>
|
||||
<td className="px-4 py-4 text-right align-middle">
|
||||
<span className="inline-flex w-fit rounded-full border border-sky-400/30 bg-sky-400/10 px-2.5 py-1 text-xs font-bold text-sky-300">
|
||||
{getNeedPendingLabel(need)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-4 text-right align-middle">
|
||||
{referenceProduct ? (
|
||||
<RouterLink
|
||||
to={buildConsumptionReferencePath({ sku: referenceProduct.productId, name: referenceProduct.name })}
|
||||
className="inline-flex h-9 w-9 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-4 w-4" />
|
||||
</RouterLink>
|
||||
) : (
|
||||
<span className="text-xs font-semibold text-dark-muted">Sem SKU</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<table className="w-full min-w-[920px] table-fixed border-collapse">
|
||||
<colgroup>
|
||||
<col />
|
||||
<col className="w-[120px]" />
|
||||
<col className="w-[120px]" />
|
||||
<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>
|
||||
<PaginationControls
|
||||
totalItems={visibleNeeds.length}
|
||||
|
||||
Reference in New Issue
Block a user