mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 13:45:23 +02:00
feat: improve Week Coverage visibility, calculation robustness, and add aggregate WOC to grid totals and forecast view
This commit is contained in:
@@ -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">
|
||||||
|
|||||||
@@ -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}`}
|
||||||
>
|
>
|
||||||
{wocText}
|
<span className={`text-[10px] font-black uppercase tracking-tighter whitespace-nowrap block ${wocColor}`}>
|
||||||
</span>
|
{wocText}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
? (yearData.map(r => r.week).filter(w => w !== undefined) as number[])
|
.filter(r => (r.units || 0) > 0 && r.week !== undefined)
|
||||||
.reduce((a, b) => Math.max(a, b), 0)
|
.map(r => r.week as number);
|
||||||
: 0;
|
|
||||||
|
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[])
|
||||||
|
.reduce((a, b) => Math.max(a, b), 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));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user