feat: improve Week Coverage visibility, calculation robustness, and add aggregate WOC to grid totals and forecast view

This commit is contained in:
Christian Vidal Wolf
2026-04-17 12:35:25 +02:00
parent aa4a916c5d
commit 38f16aac89
4 changed files with 83 additions and 14 deletions
+24 -2
View File
@@ -361,13 +361,20 @@ const ForecastView: React.FC<ForecastViewProps> = ({
return sum + (curr.monthlyData?.[month]?.forecastUnits || 0); return sum + (curr.monthlyData?.[month]?.forecastUnits || 0);
}, 0); }, 0);
const asin = curr.asin.trim().toUpperCase();
const stockData = vendorStockMap?.get(asin);
const stock = stockData ? (top50Mode === 'uk' ? stockData.uk : stockData.eu) : 0;
const velocity = curr.avgWeeklySales || 0;
return { return {
actualUnits: acc.actualUnits + (curr.actualUnits || 0), actualUnits: acc.actualUnits + (curr.actualUnits || 0),
forecastUnits: acc.forecastUnits + itemPeriodForecast, forecastUnits: acc.forecastUnits + itemPeriodForecast,
annualForecast: acc.annualForecast + (curr.annualForecast || 0), annualForecast: acc.annualForecast + (curr.annualForecast || 0),
totalStock: acc.totalStock + stock,
totalVelocity: acc.totalVelocity + velocity
}; };
}, { actualUnits: 0, forecastUnits: 0, annualForecast: 0 }); }, { actualUnits: 0, forecastUnits: 0, annualForecast: 0, totalStock: 0, totalVelocity: 0 });
}, [processedData, activeMonths]); }, [processedData, activeMonths, vendorStockMap, top50Mode]);
// [MOVED HERE] Chart Data - Now respects all filters including Search/WOC // [MOVED HERE] Chart Data - Now respects all filters including Search/WOC
const chartData = useMemo(() => { const chartData = useMemo(() => {
@@ -442,6 +449,21 @@ const ForecastView: React.FC<ForecastViewProps> = ({
<div className="text-xl font-black text-white">{globalSummary.annualForecast > 0 ? ((globalSummary.actualUnits / globalSummary.annualForecast) * 100).toFixed(1) : '0.0'}%</div> <div className="text-xl font-black text-white">{globalSummary.annualForecast > 0 ? ((globalSummary.actualUnits / globalSummary.annualForecast) * 100).toFixed(1) : '0.0'}%</div>
</div> </div>
</div> </div>
{/* New Aggregate WOC Card */}
<div className="bg-slate-900 border border-white/5 p-4 rounded-xl shadow-lg flex-1 flex flex-col justify-center">
<span className="text-[10px] font-black text-indigo-400 uppercase tracking-widest mb-1">Average Week Coverage</span>
<div className="flex items-baseline gap-2">
<div className={`text-2xl font-black ${globalSummary.totalVelocity > 0 && (globalSummary.totalStock / globalSummary.totalVelocity) < 4 ? 'text-rose-400' : 'text-emerald-400'}`}>
{globalSummary.totalVelocity > 0
? (globalSummary.totalStock / globalSummary.totalVelocity).toFixed(1)
: (globalSummary.totalStock > 0 ? '> 52' : '0.0')}
</div>
<span className="text-sm font-bold text-slate-500">Weeks</span>
</div>
<div className="text-[9px] font-bold text-slate-500 uppercase mt-1">
Total Stock: {globalSummary.totalStock.toLocaleString('de-DE')} | Velocity: {globalSummary.totalVelocity.toFixed(1)}/wk
</div>
</div>
</div> </div>
<div className="flex-1 bg-slate-900 border border-white/5 rounded-xl shadow-lg p-6 flex flex-col"> <div className="flex-1 bg-slate-900 border border-white/5 rounded-xl shadow-lg p-6 flex flex-col">
+6 -4
View File
@@ -37,9 +37,9 @@ export const VendorStockBadge: React.FC<VendorStockBadgeProps> = ({ asin, vendor
const themeClasses = "bg-slate-200 text-slate-800 border-slate-300 shadow-sm"; const themeClasses = "bg-slate-200 text-slate-800 border-slate-300 shadow-sm";
return ( return (
<div className="flex flex-col items-center gap-1 justify-center min-w-[60px]"> <div className="flex flex-col items-center gap-0.5 justify-center min-w-[70px]">
<div <div
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded border text-[10px] font-bold transition-all hover:scale-105 active:scale-95 cursor-default ${themeClasses}`} className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded border text-[10px] font-bold transition-all hover:scale-105 active:scale-95 cursor-default ${themeClasses}`}
title={`Stock en Amazon Warehouse (${mode.toUpperCase()}): ${stock}`} title={`Stock en Amazon Warehouse (${mode.toUpperCase()}): ${stock}`}
> >
<AmazonSmileIcon className="w-3.5 h-3.5" /> <AmazonSmileIcon className="w-3.5 h-3.5" />
@@ -48,12 +48,14 @@ export const VendorStockBadge: React.FC<VendorStockBadgeProps> = ({ asin, vendor
<span>{stock.toLocaleString('de-DE')}</span> <span>{stock.toLocaleString('de-DE')}</span>
</div> </div>
</div> </div>
<span <div
className={`text-[9px] font-black uppercase tracking-tight whitespace-nowrap ${wocColor}`} className={`px-1.5 py-0.5 rounded-sm bg-slate-900/40 border border-white/5 shadow-inner`}
title={`Avg Sales (4wk): ${velocity.toFixed(2)} | Stock: ${stock}`} title={`Avg Sales (4wk): ${velocity.toFixed(2)} | Stock: ${stock}`}
> >
<span className={`text-[10px] font-black uppercase tracking-tighter whitespace-nowrap block ${wocColor}`}>
{wocText} {wocText}
</span> </span>
</div> </div>
</div>
); );
}; };
+33 -1
View File
@@ -559,6 +559,26 @@ const WeeklyGrid: React.FC<WeeklyGridProps & { top50Mode: 'eu' | 'uk' }> = ({
return totals; return totals;
}, [rows, weeks]); }, [rows, weeks]);
const aggregateWoc = useMemo(() => {
if (!vendorStockMap || !velocityMap || filteredRows.length === 0) return null;
let totalStock = 0;
let totalVelocity = 0;
filteredRows.forEach(row => {
const asin = row.asin.trim().toUpperCase();
const stockData = vendorStockMap.get(asin);
if (stockData) {
totalStock += (top50Mode === 'uk' ? stockData.uk : stockData.eu);
}
totalVelocity += (velocityMap.get(asin) || 0);
});
if (totalVelocity > 0) return totalStock / totalVelocity;
if (totalStock > 0) return 999;
return 0;
}, [filteredRows, vendorStockMap, velocityMap, top50Mode]);
// 1. Filter by search term, Top 50, and growth (using debounced value) // 1. Filter by search term, Top 50, and growth (using debounced value)
const filteredRows = useMemo(() => { const filteredRows = useMemo(() => {
let result = rows.slice(); let result = rows.slice();
@@ -1112,7 +1132,19 @@ const WeeklyGrid: React.FC<WeeklyGridProps & { top50Mode: 'eu' | 'uk' }> = ({
))} ))}
</tr> </tr>
<tr className="bg-indigo-950 border-b border-white/10 relative z-20"> <tr className="bg-indigo-950 border-b border-white/10 relative z-20">
<th className="p-3 text-sm font-black text-white sticky left-0 z-40 bg-indigo-950 border-r border-white/10">TOTALS</th> <th className="p-3 text-sm font-black text-white sticky left-0 z-40 bg-indigo-950 border-r border-white/10">
<div className="flex flex-col">
<span>TOTALS</span>
{aggregateWoc !== null && (
<div className="mt-1 flex flex-col items-center">
<span className="text-[9px] text-indigo-300 uppercase font-black leading-none">Week Coverage (Avg)</span>
<span className={`text-xs font-black ${aggregateWoc < 4 ? 'text-rose-400' : 'text-emerald-400'}`}>
{aggregateWoc === 999 ? '> 52 Weeks' : `${aggregateWoc.toFixed(1)} Weeks`}
</span>
</div>
)}
</div>
</th>
{weeks.map((week, idx) => ( {weeks.map((week, idx) => (
<th key={week} className="p-3 text-sm font-black text-white text-center border-r border-white/10"> <th key={week} className="p-3 text-sm font-black text-white text-center border-r border-white/10">
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
+15 -2
View File
@@ -2470,11 +2470,24 @@ export const calculateVelocityMap = (data: SalesRecord[]): Map<string, number> =
if (validYears.length === 0) return new Map(); if (validYears.length === 0) return new Map();
const latestYear = validYears.reduce((a, b) => Math.max(a, b), 0); const latestYear = validYears.reduce((a, b) => Math.max(a, b), 0);
// Find the latest week with actual units > 0 in the latest year
// This avoids using future weeks with 0 sales that might be in the data
const yearData = data.filter(r => r.year === latestYear); const yearData = data.filter(r => r.year === latestYear);
const latestWeek = yearData.length > 0 const weeksWithSales = yearData
.filter(r => (r.units || 0) > 0 && r.week !== undefined)
.map(r => r.week as number);
let latestWeek = 0;
if (weeksWithSales.length > 0) {
latestWeek = Math.max(...weeksWithSales);
} else {
// Fallback to highest week if no sales found
latestWeek = yearData.length > 0
? (yearData.map(r => r.week).filter(w => w !== undefined) as number[]) ? (yearData.map(r => r.week).filter(w => w !== undefined) as number[])
.reduce((a, b) => Math.max(a, b), 0) .reduce((a, b) => Math.max(a, b), 0)
: 0; : 0;
}
const last4WeeksKeys = new Set<string>(); const last4WeeksKeys = new Set<string>();
for (let i = 0; i < 4; i++) { for (let i = 0; i < 4; i++) {
@@ -2492,7 +2505,7 @@ export const calculateVelocityMap = (data: SalesRecord[]): Map<string, number> =
if (r.week === undefined) return; if (r.week === undefined) return;
if (last4WeeksKeys.has(`${r.year}|${r.week}`)) { if (last4WeeksKeys.has(`${r.year}|${r.week}`)) {
const key = r.asin.trim().toUpperCase(); const key = r.asin.trim().toUpperCase();
asin4WeekSales.set(key, (asin4WeekSales.get(key) || 0) + r.units); asin4WeekSales.set(key, (asin4WeekSales.get(key) || 0) + (r.units || 0));
} }
}); });