mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 12:35:24 +02:00
Implement Weeks of Coverage indicator for Vendor Stock
This commit is contained in:
@@ -74,7 +74,7 @@ const AdsRow: React.FC<{
|
|||||||
{stockMap && (
|
{stockMap && (
|
||||||
<StockBadge stock={stockMap.get(item.sku?.replace(/(DE|EN)$/i, ''))} />
|
<StockBadge stock={stockMap.get(item.sku?.replace(/(DE|EN)$/i, ''))} />
|
||||||
)}
|
)}
|
||||||
<VendorStockBadge asin={item.asin} vendorStockMap={vendorStockMap} mode={top50Mode} />
|
<VendorStockBadge asin={item.asin} vendorStockMap={vendorStockMap} mode={top50Mode} avgWeeklySales={item.avgWeeklySales} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ const ForecastRow: React.FC<{
|
|||||||
{stockMap && (
|
{stockMap && (
|
||||||
<StockBadge stock={stockMap.get(item.sku?.replace(/(DE|EN)$/i, ''))} />
|
<StockBadge stock={stockMap.get(item.sku?.replace(/(DE|EN)$/i, ''))} />
|
||||||
)}
|
)}
|
||||||
<VendorStockBadge asin={item.asin} vendorStockMap={vendorStockMap} mode={top50Mode} />
|
<VendorStockBadge asin={item.asin} vendorStockMap={vendorStockMap} mode={top50Mode} avgWeeklySales={item.avgWeeklySales} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ interface VendorStockBadgeProps {
|
|||||||
asin: string;
|
asin: string;
|
||||||
vendorStockMap?: Map<string, { eu: number; uk: number }>;
|
vendorStockMap?: Map<string, { eu: number; uk: number }>;
|
||||||
mode: 'eu' | 'uk';
|
mode: 'eu' | 'uk';
|
||||||
|
avgWeeklySales?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const VendorStockBadge: React.FC<VendorStockBadgeProps> = ({ asin, vendorStockMap, mode }) => {
|
export const VendorStockBadge: React.FC<VendorStockBadgeProps> = ({ asin, vendorStockMap, mode, avgWeeklySales }) => {
|
||||||
if (!vendorStockMap || !asin) return null;
|
if (!vendorStockMap || !asin) return null;
|
||||||
|
|
||||||
const stockData = vendorStockMap.get(asin.toUpperCase());
|
const stockData = vendorStockMap.get(asin.toUpperCase());
|
||||||
@@ -15,48 +16,57 @@ export const VendorStockBadge: React.FC<VendorStockBadgeProps> = ({ asin, vendor
|
|||||||
|
|
||||||
const stock = mode === 'uk' ? stockData.uk : stockData.eu;
|
const stock = mode === 'uk' ? stockData.uk : stockData.eu;
|
||||||
|
|
||||||
// As per user request: "etiqueta será de color gris claro"
|
// Calculate Weeks of Coverage (WOC)
|
||||||
// Using a light gray/slate theme that fits the dashboard's premium look
|
const woc = (avgWeeklySales && avgWeeklySales > 0) ? stock / avgWeeklySales : null;
|
||||||
|
const wocColor = woc !== null ? (woc < 4 ? 'text-rose-500' : 'text-emerald-500') : 'text-slate-400';
|
||||||
|
|
||||||
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
|
<div className="flex flex-col items-center gap-0.5">
|
||||||
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}`}
|
<div
|
||||||
title={`Stock en Amazon Warehouse (${mode.toUpperCase()}): ${stock}`}
|
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}`}
|
||||||
>
|
title={`Stock en Amazon Warehouse (${mode.toUpperCase()}): ${stock}`}
|
||||||
<svg
|
|
||||||
className="w-3.5 h-3.5"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
>
|
||||||
<path
|
<svg
|
||||||
d="M17.5 14c.5 0 1 .5 1 1s-.5 1-1 1-1-.5-1-1 .5-1 1-1zm-11 0c.5 0 1 .5 1 1s-.5 1-1 1-1-.5-1-1 .5-1 1-1z"
|
className="w-3.5 h-3.5"
|
||||||
fill="currentColor"
|
viewBox="0 0 24 24"
|
||||||
/>
|
fill="none"
|
||||||
<path
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
d="M3 13.5c0 4.7 3.8 8.5 8.5 8.5s8.5-3.8 8.5-8.5"
|
>
|
||||||
stroke="currentColor"
|
<path
|
||||||
strokeWidth="1.5"
|
d="M17.5 14c.5 0 1 .5 1 1s-.5 1-1 1-1-.5-1-1 .5-1 1-1zm-11 0c.5 0 1 .5 1 1s-.5 1-1 1-1-.5-1-1 .5-1 1-1z"
|
||||||
strokeLinecap="round"
|
fill="currentColor"
|
||||||
/>
|
/>
|
||||||
<path
|
<path
|
||||||
d="M17 18.5c-1.5 1.5-3.5 2.5-5.5 2.5s-4-1-5.5-2.5"
|
d="M3 13.5c0 4.7 3.8 8.5 8.5 8.5s8.5-3.8 8.5-8.5"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
strokeWidth="1.5"
|
strokeWidth="1.5"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
/>
|
/>
|
||||||
<path
|
<path
|
||||||
d="M19 18.5l1.5 1.5M5 18.5L3.5 20"
|
d="M17 18.5c-1.5 1.5-3.5 2.5-5.5 2.5s-4-1-5.5-2.5"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
strokeWidth="1.5"
|
strokeWidth="1.5"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
/>
|
/>
|
||||||
</svg>
|
<path
|
||||||
<div className="flex flex-col leading-[0.8]">
|
d="M19 18.5l1.5 1.5M5 18.5L3.5 20"
|
||||||
<span className="text-[7px] font-black uppercase opacity-60">Vendor</span>
|
stroke="currentColor"
|
||||||
<span>{stock.toLocaleString('de-DE')}</span>
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div className="flex flex-col leading-[0.8]">
|
||||||
|
<span className="text-[7px] font-black uppercase opacity-60">Vendor</span>
|
||||||
|
<span>{stock.toLocaleString('de-DE')}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{woc !== null && (
|
||||||
|
<div className={`text-[9px] font-black uppercase tracking-tighter ${wocColor}`}>
|
||||||
|
{woc.toFixed(1)} Weeks Cover
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ const WeeklyRow: React.FC<{
|
|||||||
{stockMap && (
|
{stockMap && (
|
||||||
<StockBadge stock={stockMap.get(row.sku?.replace(/(DE|EN)$/i, ''))} />
|
<StockBadge stock={stockMap.get(row.sku?.replace(/(DE|EN)$/i, ''))} />
|
||||||
)}
|
)}
|
||||||
<VendorStockBadge asin={row.asin} vendorStockMap={vendorStockMap} mode={top50Mode} />
|
<VendorStockBadge asin={row.asin} vendorStockMap={vendorStockMap} mode={top50Mode} avgWeeklySales={row.avgWeeklySales} />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[9px] text-fuchsia-400/80 font-bold uppercase tracking-widest">{row.line}</span>
|
<span className="text-[9px] text-fuchsia-400/80 font-bold uppercase tracking-widest">{row.line}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -598,10 +598,35 @@ export const mergeSalesAndAdsData = (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 4. Calculate 4-Week Average Sales per ASIN
|
||||||
|
const latestYear = Math.max(...salesData.map(r => r.year).filter(y => y > 0));
|
||||||
|
const yearData = salesData.filter(r => r.year === latestYear);
|
||||||
|
const latestWeek = yearData.length > 0 ? Math.max(...yearData.map(r => r.week).filter(w => w !== undefined) as number[]) : 0;
|
||||||
|
|
||||||
|
const last4WeeksKeys = new Set<string>();
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
let w = latestWeek - i;
|
||||||
|
let y = latestYear;
|
||||||
|
if (w <= 0) {
|
||||||
|
w = 52 + w;
|
||||||
|
y = latestYear - 1;
|
||||||
|
}
|
||||||
|
last4WeeksKeys.add(`${y}|${w}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const asin4WeekSales = new Map<string, number>();
|
||||||
|
salesData.forEach(r => {
|
||||||
|
if (r.week === undefined) return;
|
||||||
|
if (last4WeeksKeys.has(`${r.year}|${r.week}`)) {
|
||||||
|
const key = r.asin.trim().toUpperCase();
|
||||||
|
asin4WeekSales.set(key, (asin4WeekSales.get(key) || 0) + r.units);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const mergedData: CombinedKPIs[] = [];
|
const mergedData: CombinedKPIs[] = [];
|
||||||
const processedKeys = new Set<string>();
|
const processedKeys = new Set<string>();
|
||||||
|
|
||||||
// 4. Create ONE record per ASIN/Customer/Year/Week from sales
|
// 5. Create ONE record per ASIN/Customer/Year/Week from sales
|
||||||
salesMap.forEach((sale, key) => {
|
salesMap.forEach((sale, key) => {
|
||||||
processedKeys.add(key);
|
processedKeys.add(key);
|
||||||
const ad = adsMap.get(key);
|
const ad = adsMap.get(key);
|
||||||
@@ -623,6 +648,7 @@ export const mergeSalesAndAdsData = (
|
|||||||
const ctr = adImpressions > 0 ? (adClicks / adImpressions) * 100 : 0;
|
const ctr = adImpressions > 0 ? (adClicks / adImpressions) * 100 : 0;
|
||||||
const cpc = adClicks > 0 ? adCost / adClicks : 0;
|
const cpc = adClicks > 0 ? adCost / adClicks : 0;
|
||||||
const cvrUnits = adClicks > 0 ? (adUnits / adClicks) * 100 : 0;
|
const cvrUnits = adClicks > 0 ? (adUnits / adClicks) * 100 : 0;
|
||||||
|
const avgWeeklySales = (asin4WeekSales.get(sale.asin.trim().toUpperCase()) || 0) / 4;
|
||||||
|
|
||||||
mergedData.push({
|
mergedData.push({
|
||||||
id: `merged-${key}`,
|
id: `merged-${key}`,
|
||||||
@@ -653,7 +679,8 @@ export const mergeSalesAndAdsData = (
|
|||||||
ctr,
|
ctr,
|
||||||
cpc,
|
cpc,
|
||||||
cvrUnits,
|
cvrUnits,
|
||||||
glanceViews: trafficMap.get(key) || 0
|
glanceViews: trafficMap.get(key) || 0,
|
||||||
|
avgWeeklySales
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -662,6 +689,7 @@ export const mergeSalesAndAdsData = (
|
|||||||
if (!processedKeys.has(key)) {
|
if (!processedKeys.has(key)) {
|
||||||
const asin = ad.asin.trim().toUpperCase();
|
const asin = ad.asin.trim().toUpperCase();
|
||||||
const meta = asinMetadata.get(asin);
|
const meta = asinMetadata.get(asin);
|
||||||
|
const avgWeeklySales = (asin4WeekSales.get(asin) || 0) / 4;
|
||||||
|
|
||||||
mergedData.push({
|
mergedData.push({
|
||||||
id: `ads-only-${key}`,
|
id: `ads-only-${key}`,
|
||||||
@@ -692,7 +720,8 @@ export const mergeSalesAndAdsData = (
|
|||||||
ctr: ad.impressions > 0 ? (ad.clicks / ad.impressions) * 100 : 0,
|
ctr: ad.impressions > 0 ? (ad.clicks / ad.impressions) * 100 : 0,
|
||||||
cpc: ad.clicks > 0 ? ad.cost / ad.clicks : 0,
|
cpc: ad.clicks > 0 ? ad.cost / ad.clicks : 0,
|
||||||
cvrUnits: ad.clicks > 0 ? (ad.attributedUnits30d / ad.clicks) * 100 : 0,
|
cvrUnits: ad.clicks > 0 ? (ad.attributedUnits30d / ad.clicks) * 100 : 0,
|
||||||
glanceViews: trafficMap.get(key) || 0
|
glanceViews: trafficMap.get(key) || 0,
|
||||||
|
avgWeeklySales
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1684,9 +1713,35 @@ export const calculateForecastViewData = (
|
|||||||
monthMap.set(m, (monthMap.get(m) || 0) + r.units);
|
monthMap.set(m, (monthMap.get(m) || 0) + r.units);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 4-Week Average Sales Calculation
|
||||||
|
const latestYear = Math.max(...rawData.map(r => r.year).filter(y => y > 0));
|
||||||
|
const yearData = rawData.filter(r => r.year === latestYear);
|
||||||
|
const latestWeek = yearData.length > 0 ? Math.max(...yearData.map(r => r.week).filter(w => w !== undefined) as number[]) : 0;
|
||||||
|
|
||||||
|
const last4WeeksKeys = new Set<string>();
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
let w = latestWeek - i;
|
||||||
|
let y = latestYear;
|
||||||
|
if (w <= 0) {
|
||||||
|
w = 52 + w;
|
||||||
|
y = latestYear - 1;
|
||||||
|
}
|
||||||
|
last4WeeksKeys.add(`${y}|${w}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const asin4WeekSales = new Map<string, number>();
|
||||||
|
rawData.forEach(r => {
|
||||||
|
if (r.week === undefined) return;
|
||||||
|
if (last4WeeksKeys.has(`${r.year}|${r.week}`)) {
|
||||||
|
const key = r.asin.trim().toUpperCase();
|
||||||
|
asin4WeekSales.set(key, (asin4WeekSales.get(key) || 0) + r.units);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return forecastData.map(fc => {
|
return forecastData.map(fc => {
|
||||||
const identifier = fc.asin.toUpperCase();
|
const identifier = fc.asin.toUpperCase();
|
||||||
const meta = asinMetadata.get(identifier);
|
const meta = asinMetadata.get(identifier);
|
||||||
|
const avgWeeklySales = (asin4WeekSales.get(identifier) || 0) / 4;
|
||||||
|
|
||||||
// 2. Determine weights for this ASIN
|
// 2. Determine weights for this ASIN
|
||||||
const productRecords2025 = dataByAsin2025.get(identifier) || [];
|
const productRecords2025 = dataByAsin2025.get(identifier) || [];
|
||||||
@@ -1735,6 +1790,7 @@ export const calculateForecastViewData = (
|
|||||||
actualUnits: totalActualUnits,
|
actualUnits: totalActualUnits,
|
||||||
forecastUnits: totalForecastUnits,
|
forecastUnits: totalForecastUnits,
|
||||||
accuracy: Math.max(0, accuracy),
|
accuracy: Math.max(0, accuracy),
|
||||||
|
avgWeeklySales,
|
||||||
monthlyData
|
monthlyData
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -195,6 +195,7 @@ export interface CombinedKPIs {
|
|||||||
cpc: number;
|
cpc: number;
|
||||||
cvrUnits: number;
|
cvrUnits: number;
|
||||||
glanceViews: number; // Traffic / page views
|
glanceViews: number; // Traffic / page views
|
||||||
|
avgWeeklySales?: number; // Added for Weeks of Coverage
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ForecastRecord {
|
export interface ForecastRecord {
|
||||||
@@ -220,5 +221,6 @@ export interface ProductForecastData {
|
|||||||
actualUnits: number;
|
actualUnits: number;
|
||||||
forecastUnits: number;
|
forecastUnits: number;
|
||||||
accuracy: number;
|
accuracy: number;
|
||||||
|
avgWeeklySales: number; // Added for Weeks of Coverage
|
||||||
monthlyData: Record<string, MonthlyForecastPoint>;
|
monthlyData: Record<string, MonthlyForecastPoint>;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user