Clarify cutting blockers and group replenishment context

This commit is contained in:
Cauê Faleiros
2026-07-09 11:16:10 -03:00
parent 34a75e64f6
commit 423442578f
2 changed files with 135 additions and 15 deletions

View File

@@ -26,7 +26,7 @@ const familyOptions: Array<{ value: CutFamilyKey | 'all'; label: string }> = [
const filterOptions: Array<{ value: CutFilter; label: string }> = [
{ value: 'need', label: 'Com necessidade' },
{ value: 'all', label: 'Todos' },
{ value: 'issues', label: 'Pendências' },
{ value: 'issues', label: 'Dados pendentes' },
{ value: 'covered', label: 'Sem necessidade' }
];
@@ -45,7 +45,7 @@ const issueHelp: Record<CutIssue, string> = {
};
const correctionFilterOptions: Array<{ value: CorrectionIssueFilter; label: string }> = [
{ value: 'all', label: 'Todas pendências' },
{ value: 'all', label: 'Todos os dados pendentes' },
{ value: 'missing_family_rule', label: issueLabels.missing_family_rule },
{ value: 'missing_color', label: issueLabels.missing_color },
{ value: 'missing_size', label: issueLabels.missing_size }
@@ -368,7 +368,7 @@ const Cutting = () => {
'Rendimento un/rolo': row.family.unitsPerRoll || '',
'Rolos estimados': row.estimatedRolls || '',
'Cobertura': row.daysOfCover === null ? '' : row.daysOfCover.toFixed(1).replace('.', ','),
'Pendências': row.issues.map(issue => issueLabels[issue]).join(' | ')
'Dados pendentes': row.issues.map(issue => issueLabels[issue]).join(' | ')
})), `plano_corte_${new Date().toISOString().split('T')[0]}.csv`);
};
@@ -386,7 +386,7 @@ const Cutting = () => {
>
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">
{row.issues.length === 1 ? issueLabels[row.issues[0]] : `${row.issues.length} pendências`}
{row.issues.length === 1 ? issueLabels[row.issues[0]] : `${row.issues.length} dados`}
</span>
</span>
);
@@ -627,7 +627,7 @@ const Cutting = () => {
) : (
<div className="px-4 py-8 text-center">
<p className="text-sm font-bold text-dark-text">Nenhuma correção de produto pendente.</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">As pendências restantes, se existirem, são de rendimento por família.</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">Os dados restantes, se existirem, são de rendimento por família.</p>
</div>
)}
</div>
@@ -697,8 +697,8 @@ const Cutting = () => {
<div className="rounded-2xl border border-dark-border bg-dark-card p-4 shadow-sm">
<div className="mb-3 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<h2 className="text-sm font-bold text-dark-text">Pendências do plano</h2>
<p className="mt-1 text-xs font-semibold text-dark-muted">Itens que ainda precisam de regra, cor, tamanho ou rendimento.</p>
<h2 className="text-sm font-bold text-dark-text">Dados pendentes do plano</h2>
<p className="mt-1 text-xs font-semibold text-dark-muted">Dados que bloqueiam o cálculo completo: família, cor, tamanho ou rendimento.</p>
</div>
<div className="flex items-center gap-2">
<span className="rounded-full border border-amber-400/30 bg-amber-400/10 px-3 py-1 text-xs font-bold text-amber-300">
@@ -875,7 +875,12 @@ const Cutting = () => {
<th className="px-4 py-4 text-[10px] font-bold uppercase tracking-wider">Estoque</th>
<th className="px-4 py-4 text-[10px] font-bold uppercase tracking-wider">Necessidade</th>
<th className="px-4 py-4 text-[10px] font-bold uppercase tracking-wider">Rolos</th>
<th className="px-4 py-4 text-[10px] font-bold uppercase tracking-wider">Pendências</th>
<th
className="px-4 py-4 text-[10px] font-bold uppercase tracking-wider"
title="Dados que ainda faltam para fechar o plano de corte do SKU."
>
Dados pendentes
</th>
<th className="px-4 py-4 text-right text-[10px] font-bold uppercase tracking-wider">Ações</th>
</tr>
</thead>

View File

@@ -6,14 +6,19 @@ import DateRangePicker from '../components/DateRangePicker';
import PaginationControls from '../components/PaginationControls';
import ProductColorBadge, { ProductColorSwatch, getProductColor } from '../components/ProductColorBadge';
import RefreshStatus from '../components/RefreshStatus';
import { fetchProductAnalytics } from '../dataService';
import { buildOpenProductionByProductId } from '../analytics/cutting';
import { fetchProductAnalytics, fetchProductionOrders } from '../dataService';
import { decodeProductGroupKey, normalizeProductText, parseProductName } from '../productParsing';
import type { DateRange, ProductAnalyticsItem } from '../types';
import type { DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types';
type VariantRow = ProductAnalyticsItem & {
color: string;
size: string;
dailySales: number;
projectedDemand: number;
openProductionQuantity: number;
availableQuantity: number;
suggestedReplenishment: number;
daysOfCover: number | null;
};
@@ -26,6 +31,12 @@ type BreakdownRow = {
};
const BREAKDOWN_LIMIT = 12;
const REPLENISHMENT_TARGET_DAYS = 30;
const allProductionOrdersRange = {
start: new Date(2000, 0, 1),
end: new Date(2100, 11, 31)
};
const getRangeDays = (range: DateRange) => {
const start = new Date(range.start);
@@ -174,6 +185,7 @@ const ProductGroupDetails = () => {
setDateRange: (range: DateRange) => void
}>();
const [products, setProducts] = useState<ProductAnalyticsItem[]>([]);
const [productionOrders, setProductionOrders] = useState<ProductionOrderItem[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(20);
@@ -193,10 +205,14 @@ const ProductGroupDetails = () => {
const loadProducts = async () => {
setIsLoading(true);
const data = await fetchProductAnalytics(dateRange);
const [productData, productionOrderData] = await Promise.all([
fetchProductAnalytics(dateRange),
fetchProductionOrders(allProductionOrdersRange)
]);
if (isMounted) {
setProducts(data);
setProducts(productData);
setProductionOrders(productionOrderData.orders);
setIsLoading(false);
}
};
@@ -208,6 +224,11 @@ const ProductGroupDetails = () => {
};
}, [dateRange]);
const openProductionByProductId = useMemo(
() => buildOpenProductionByProductId(products, productionOrders),
[products, productionOrders]
);
const groupRows = useMemo<VariantRow[]>(() => {
const rangeDays = getRangeDays(dateRange);
const normalizedGroupName = normalizeProductText(groupName).toLowerCase();
@@ -216,6 +237,10 @@ const ProductGroupDetails = () => {
.map(product => {
const metadata = parseProductName(product.name);
const dailySales = product.quantitySold / rangeDays;
const projectedDemand = dailySales * REPLENISHMENT_TARGET_DAYS;
const openProductionQuantity = openProductionByProductId[product.id] || 0;
const availableQuantity = product.stock + openProductionQuantity;
const suggestedReplenishment = Math.max(0, Math.ceil(projectedDemand - availableQuantity));
return {
...product,
@@ -223,19 +248,27 @@ const ProductGroupDetails = () => {
size: metadata.size,
baseName: metadata.baseName,
dailySales,
daysOfCover: dailySales > 0 ? product.stock / dailySales : null
projectedDemand,
openProductionQuantity,
availableQuantity,
suggestedReplenishment,
daysOfCover: dailySales > 0 ? availableQuantity / dailySales : null
};
})
.filter(product => normalizeProductText(product.baseName).toLowerCase() === normalizedGroupName)
.sort((a, b) => b.quantitySold - a.quantitySold);
}, [dateRange, groupName, products]);
}, [dateRange, groupName, openProductionByProductId, products]);
const totals = useMemo(() => {
const totalSold = groupRows.reduce((total, row) => total + row.quantitySold, 0);
const totalRevenue = groupRows.reduce((total, row) => total + row.revenue, 0);
const totalStock = groupRows.reduce((total, row) => total + row.stock, 0);
const openProductionQuantity = groupRows.reduce((total, row) => total + row.openProductionQuantity, 0);
const availableQuantity = groupRows.reduce((total, row) => total + row.availableQuantity, 0);
const dailySales = groupRows.reduce((total, row) => total + row.dailySales, 0);
const daysOfCover = dailySales > 0 ? totalStock / dailySales : null;
const projectedDemand = groupRows.reduce((total, row) => total + row.projectedDemand, 0);
const suggestedReplenishment = Math.max(0, Math.ceil(projectedDemand - availableQuantity));
const daysOfCover = dailySales > 0 ? availableQuantity / dailySales : null;
const colors = new Set(groupRows.map(row => row.color).filter(Boolean));
const sizes = new Set(groupRows.map(row => row.size).filter(Boolean));
@@ -243,7 +276,11 @@ const ProductGroupDetails = () => {
totalSold,
totalRevenue,
totalStock,
openProductionQuantity,
availableQuantity,
dailySales,
projectedDemand,
suggestedReplenishment,
daysOfCover,
colorCount: colors.size,
sizeCount: sizes.size
@@ -252,6 +289,12 @@ const ProductGroupDetails = () => {
const colorBreakdown = useMemo(() => buildBreakdown(groupRows, 'color'), [groupRows]);
const sizeBreakdown = useMemo(() => buildBreakdown(groupRows, 'size'), [groupRows]);
const replenishmentDrivers = useMemo(() => (
groupRows
.filter(row => row.suggestedReplenishment > 0)
.sort((a, b) => b.suggestedReplenishment - a.suggestedReplenishment)
.slice(0, 5)
), [groupRows]);
const isRefreshing = isLoading && products.length > 0;
const totalPages = Math.ceil(groupRows.length / itemsPerPage);
const safeCurrentPage = Math.min(currentPage, totalPages || 1);
@@ -346,6 +389,78 @@ const ProductGroupDetails = () => {
</div>
</div>
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
<div className="mb-4 flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
<div>
<h2 className="text-base font-bold text-dark-text">Contexto de reposição</h2>
<p className="mt-1 text-sm font-medium text-dark-muted">
Projeção para {REPLENISHMENT_TARGET_DAYS} dias usando vendas do período, estoque atual e OP aberta quando encontrada.
</p>
</div>
<span className={`w-fit rounded-full border px-3 py-1 text-xs font-bold ${totals.suggestedReplenishment > 0 ? 'border-red-400/30 bg-red-400/10 text-red-300' : 'border-emerald-400/30 bg-emerald-400/10 text-emerald-300'}`}>
{totals.suggestedReplenishment > 0 ? 'Com necessidade' : 'Coberto'}
</span>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-4">
<div className="rounded-xl border border-dark-border bg-dark-input/50 p-3">
<p className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">Sugestão</p>
<p className={`mt-2 text-2xl font-bold ${totals.suggestedReplenishment > 0 ? 'text-red-300' : 'text-emerald-300'}`}>
{formatNumber(totals.suggestedReplenishment)} un.
</p>
</div>
<div className="rounded-xl border border-dark-border bg-dark-input/50 p-3">
<p className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">Demanda 30 dias</p>
<p className="mt-2 text-2xl font-bold text-dark-text">{formatNumber(totals.projectedDemand, 1)} un.</p>
</div>
<div className="rounded-xl border border-dark-border bg-dark-input/50 p-3">
<p className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">Disponível</p>
<p className="mt-2 text-2xl font-bold text-dark-text">{formatNumber(totals.availableQuantity)} un.</p>
{!!totals.openProductionQuantity && (
<p className="mt-1 text-xs font-semibold text-dark-muted">Inclui OP {formatNumber(totals.openProductionQuantity)} un.</p>
)}
</div>
<div className="rounded-xl border border-dark-border bg-dark-input/50 p-3">
<p className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">Cobertura</p>
<p className="mt-2 text-2xl font-bold text-dark-text">{formatDays(totals.daysOfCover)}</p>
</div>
</div>
<div className="mt-4 rounded-xl border border-dark-border bg-dark-input/35">
<div className="border-b border-dark-border px-4 py-3">
<h3 className="text-xs font-bold uppercase tracking-widest text-dark-muted">Principais drivers</h3>
</div>
{replenishmentDrivers.length ? (
<div className="divide-y divide-dark-border">
{replenishmentDrivers.map(row => (
<div key={row.id} className="grid grid-cols-[1fr_auto] gap-4 px-4 py-3">
<div className="min-w-0">
<p className="truncate text-sm font-bold text-dark-text" title={row.name}>{row.name}</p>
<div className="mt-1 flex flex-wrap items-center gap-2">
<ProductColorBadge label={row.color} className="max-w-[8rem]" />
<span className="rounded-full border border-emerald-400/25 bg-emerald-400/10 px-2.5 py-1 text-xs font-bold text-emerald-300">
{row.size || 'Sem tamanho'}
</span>
<span className="text-xs font-semibold text-dark-muted">
Cobertura {formatDays(row.daysOfCover)}
</span>
</div>
</div>
<div className="text-right">
<p className="text-sm font-bold text-red-300">{formatNumber(row.suggestedReplenishment)} un.</p>
<p className="mt-1 text-[10px] font-semibold text-dark-muted">sugerido</p>
</div>
</div>
))}
</div>
) : (
<div className="px-4 py-6 text-sm font-semibold text-dark-muted">
Nenhum SKU do grupo está abaixo da cobertura projetada.
</div>
)}
</div>
</div>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
<BreakdownPanel
title="Venda por cor"