Clarify cutting blockers and group replenishment context
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user