From eadbaf3492d32d1897fdac36c0334c377211dc84 Mon Sep 17 00:00:00 2001 From: Christian Vidal Wolf Date: Mon, 27 Apr 2026 11:22:53 +0200 Subject: [PATCH] Feat: add hover tooltip on TOTALS row in Weekly Sales with WoW and YoY comparison Shows previous week and same week prior year values (Units, Revenue, Ads Spend, GV) with % change indicators when hovering any week cell in the TOTALS row. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- App.tsx | 22 +++++++- components/AdsPerformance.tsx | 11 ++-- components/DataGrid.tsx | 8 +-- components/ForecastView.tsx | 10 +++- components/VendorDataView.tsx | 32 ++++++++++- components/WeeklyGrid.tsx | 100 ++++++++++++++++++++++++++++----- components/mkt/MktDataView.tsx | 20 ++++++- 7 files changed, 172 insertions(+), 31 deletions(-) diff --git a/App.tsx b/App.tsx index 2befa0e..fe4c843 100644 --- a/App.tsx +++ b/App.tsx @@ -921,6 +921,7 @@ const App: React.FC = () => { onWocFilterChange={(s) => setFilters(prev => ({ ...prev, woc: s }))} top50Mode={filters.customer.includes('Amazon UK') ? 'uk' : 'eu'} buyBoxLostMap={buyBoxLostMap} + velocityMap={velocityMap} /> @@ -947,7 +948,16 @@ const App: React.FC = () => { }>
- +
@@ -962,7 +972,15 @@ const App: React.FC = () => { }>
- +
diff --git a/components/AdsPerformance.tsx b/components/AdsPerformance.tsx index 71a5475..23609af 100644 --- a/components/AdsPerformance.tsx +++ b/components/AdsPerformance.tsx @@ -26,6 +26,7 @@ interface AdsPerformanceProps { wocFilter: string[]; onWocFilterChange: (newFilters: string[]) => void; buyBoxLostMap?: Map }>; + velocityMap?: Map; } type SortKey = keyof CombinedKPIs | 'acos' | 'roas' | 'tacos' | 'ctr' | 'cpc' | 'cvrUnits'; @@ -48,7 +49,8 @@ const AdsRow: React.FC<{ stockMap?: Map; vendorStockMap?: Map; buyBoxLostMap?: Map }>; -}> = React.memo(({ item, top50Ranking, top50Mode, stockMap, vendorStockMap, buyBoxLostMap }) => { + velocityMap?: Map; +}> = React.memo(({ item, top50Ranking, top50Mode, stockMap, vendorStockMap, buyBoxLostMap, velocityMap }) => { const asin = item.asin.trim().toUpperCase(); const ranks: { rank: number; label: string; theme: 'amber' | 'blue' | 'indigo' }[] = []; @@ -80,7 +82,7 @@ const AdsRow: React.FC<{ {stockMap && ( )} - + @@ -116,7 +118,8 @@ const AdsPerformance: React.FC = ({ wocFilter, onWocFilterChange, top50Mode, - buyBoxLostMap + buyBoxLostMap, + velocityMap }) => { const [searchTerm, setSearchTerm] = useState(''); const [showOnlyTop50, setShowOnlyTop50] = useState(false); @@ -487,7 +490,7 @@ const AdsPerformance: React.FC = ({ {filteredAndSorted.map((p) => ( - + ))} diff --git a/components/DataGrid.tsx b/components/DataGrid.tsx index cb30d1b..ecf75ed 100644 --- a/components/DataGrid.tsx +++ b/components/DataGrid.tsx @@ -1385,12 +1385,10 @@ const DataGrid: React.FC = ({ data, filters, hasCustomerFilter, a {row.title} {stockMap && ( - <> - - - - + )} + + : dim === 'asin' || dim === 'sku' ?
diff --git a/components/ForecastView.tsx b/components/ForecastView.tsx index 7d537d9..cfd629e 100644 --- a/components/ForecastView.tsx +++ b/components/ForecastView.tsx @@ -30,6 +30,7 @@ interface ForecastViewProps { wocFilter: string[]; onWocFilterChange: (newFilters: string[]) => void; buyBoxLostMap?: Map }>; + velocityMap?: Map; } const MONTH_ORDER = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; @@ -42,7 +43,8 @@ const ForecastRow: React.FC<{ stockMap?: Map; vendorStockMap?: Map; buyBoxLostMap?: Map }>; -}> = React.memo(({ item, activeMonths, top50Ranking, top50Mode, stockMap, vendorStockMap, buyBoxLostMap }) => { + velocityMap?: Map; +}> = React.memo(({ item, activeMonths, top50Ranking, top50Mode, stockMap, vendorStockMap, buyBoxLostMap, velocityMap }) => { const asin = item.asin.trim().toUpperCase(); const ranks: { rank: number; label: string; theme: 'amber' | 'blue' | 'indigo' }[] = []; @@ -109,7 +111,7 @@ const ForecastRow: React.FC<{ )} {/* WOC Indicator preserved */} - +
@@ -181,7 +183,8 @@ const ForecastView: React.FC = ({ wocFilter, onWocFilterChange, top50Mode, - buyBoxLostMap + buyBoxLostMap, + velocityMap }) => { const [searchTerm, setSearchTerm] = useState(''); const [debouncedSearch, setDebouncedSearch] = useState(''); @@ -660,6 +663,7 @@ const ForecastView: React.FC = ({ stockMap={stockMap} vendorStockMap={vendorStockMap} buyBoxLostMap={buyBoxLostMap} + velocityMap={velocityMap} /> ))} diff --git a/components/VendorDataView.tsx b/components/VendorDataView.tsx index b757ca4..3cb20e2 100644 --- a/components/VendorDataView.tsx +++ b/components/VendorDataView.tsx @@ -2,6 +2,8 @@ import React, { useMemo } from 'react'; import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'; import { BSRRecord, CombinedKPIs } from '../types'; import { BuyBoxWarningBadge } from './BuyBoxWarningBadge'; +import { StockBadge } from './StockBadge'; +import { VendorStockBadge } from './VendorStockBadge'; import { BSRUnitsCorrelationChart } from './BSRUnitsCorrelationChart'; interface VendorDataViewProps { @@ -9,6 +11,10 @@ interface VendorDataViewProps { asinMetadata?: Map; buyBoxLostMap?: Map }>; combinedSalesData?: CombinedKPIs[]; + stockMap?: Map; + vendorStockMap?: Map; + top50Mode?: 'eu' | 'uk'; + velocityMap?: Map; } interface ChartPoint { @@ -306,7 +312,16 @@ const CATEGORY_TRANSLATIONS: Record = { const translateCategory = (name: string): string => CATEGORY_TRANSLATIONS[name] ?? name; -const VendorDataView: React.FC = ({ bsrData = [], asinMetadata, buyBoxLostMap, combinedSalesData = [] }) => { +const VendorDataView: React.FC = ({ + bsrData = [], + asinMetadata, + buyBoxLostMap, + combinedSalesData = [], + stockMap, + vendorStockMap, + top50Mode = 'eu', + velocityMap +}) => { const activeMarkets = useMemo(() => { const m = new Set(bsrData.map(r => r.market)); return Array.from(m).sort(); @@ -461,8 +476,19 @@ const VendorDataView: React.FC = ({ bsrData = [], asinMetad -
- +
+
+ {stockMap && ( + + )} + + +
diff --git a/components/WeeklyGrid.tsx b/components/WeeklyGrid.tsx index 1873b1b..c54fa68 100644 --- a/components/WeeklyGrid.tsx +++ b/components/WeeklyGrid.tsx @@ -94,6 +94,7 @@ const WeeklyGrid: React.FC = ({ const [displayCount, setDisplayCount] = useState(50); const [primaryMetric, setPrimaryMetric] = useState<'units' | 'revenue'>('units'); const scrollContainerRef = useRef(null); + const [hoveredTotalWeek, setHoveredTotalWeek] = useState(null); // State for Column Filters (SKU, ASIN, Title, Line) const [columnFilters, setColumnFilters] = useState>({}); @@ -175,6 +176,24 @@ const WeeklyGrid: React.FC = ({ return totals; }, [rows, weeks]); + // Totals for ALL weeks (for YoY tooltip comparison) + const allWeekTotals = useMemo(() => { + const totals: { [weekKey: string]: { units: number, spend: number, revenue: number, gv: number } } = {}; + for (let i = 0; i < allWeeks.length; i++) { + totals[allWeeks[i]] = { units: 0, spend: 0, revenue: 0, gv: 0 }; + } + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + for (let j = 0; j < allWeeks.length; j++) { + const w = allWeeks[j]; + totals[w].units += (row.unitsByWeek[w] || 0); + totals[w].spend += (row.spendByWeek[w] || 0); + totals[w].revenue += (row.revenueByWeek[w] || 0); + totals[w].gv += (row.gvByWeek?.[w] || 0); + } + } + return totals; + }, [rows, allWeeks]); // 1. Filter by search term, Top 50, and growth (using debounced value) const filteredRows = useMemo(() => { @@ -770,39 +789,94 @@ const WeeklyGrid: React.FC = ({ - {weeks.map((week, idx) => ( - + {weeks.map((week, idx) => { + const [yearStr, weekNum] = week.split('-'); + const prevYearWeek = `${parseInt(yearStr) - 1}-${weekNum}`; + const prevWeekTotals = allWeekTotals[weeks[idx + 1]]; + const yoyTotals = allWeekTotals[prevYearWeek]; + const cur = weekTotals[week]; + const isHovered = hoveredTotalWeek === week; + + return ( + setHoveredTotalWeek(week)} + onMouseLeave={() => setHoveredTotalWeek(null)} + >
{primaryMetric === 'units' ? ( <>
- {weekTotals[week]?.units.toLocaleString('de-DE') || 0} - {renderGrowth(weekTotals[week]?.units || 0, weekTotals[weeks[idx + 1]]?.units || 0)} + {cur?.units.toLocaleString('de-DE') || 0} + {renderGrowth(cur?.units || 0, prevWeekTotals?.units || 0)}
- Ads: €{(weekTotals[week]?.spend || 0).toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 })} - {renderGrowth(weekTotals[week]?.spend || 0, weekTotals[weeks[idx + 1]]?.spend || 0)} + Ads: €{(cur?.spend || 0).toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 })} + {renderGrowth(cur?.spend || 0, prevWeekTotals?.spend || 0)}
) : ( <>
- €{(weekTotals[week]?.revenue || 0).toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 })} - {renderGrowth(weekTotals[week]?.revenue || 0, weekTotals[weeks[idx + 1]]?.revenue || 0)} + €{(cur?.revenue || 0).toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 })} + {renderGrowth(cur?.revenue || 0, prevWeekTotals?.revenue || 0)}
- Ads: €{(weekTotals[week]?.spend || 0).toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 })} - {renderGrowth(weekTotals[week]?.spend || 0, weekTotals[weeks[idx + 1]]?.spend || 0)} + Ads: €{(cur?.spend || 0).toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 })} + {renderGrowth(cur?.spend || 0, prevWeekTotals?.spend || 0)}
)}
- GV: {(weekTotals[week]?.gv || 0).toLocaleString('de-DE')} - {renderGrowth(weekTotals[week]?.gv || 0, weekTotals[weeks[idx + 1]]?.gv || 0)} + GV: {(cur?.gv || 0).toLocaleString('de-DE')} + {renderGrowth(cur?.gv || 0, prevWeekTotals?.gv || 0)}
+ + {isHovered && ( +
+
{week}
+ + {/* Semana anterior */} +
+
Semana anterior ({weeks[idx + 1] || '—'})
+ {prevWeekTotals ? ( +
+ Units + {prevWeekTotals.units.toLocaleString('de-DE')} {renderGrowth(cur?.units || 0, prevWeekTotals.units)} + Revenue + €{prevWeekTotals.revenue.toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 })} {renderGrowth(cur?.revenue || 0, prevWeekTotals.revenue)} + Ads Spend + €{prevWeekTotals.spend.toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 })} {renderGrowth(cur?.spend || 0, prevWeekTotals.spend)} + GV + {prevWeekTotals.gv.toLocaleString('de-DE')} {renderGrowth(cur?.gv || 0, prevWeekTotals.gv)} +
+ ) : ( + Sin datos + )} +
+ +
+
Misma semana año anterior ({prevYearWeek})
+ {yoyTotals && (yoyTotals.units > 0 || yoyTotals.revenue > 0) ? ( +
+ Units + {yoyTotals.units.toLocaleString('de-DE')} {renderGrowth(cur?.units || 0, yoyTotals.units)} + Revenue + €{yoyTotals.revenue.toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 })} {renderGrowth(cur?.revenue || 0, yoyTotals.revenue)} + Ads Spend + €{yoyTotals.spend.toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 })} {renderGrowth(cur?.spend || 0, yoyTotals.spend)} + GV + {yoyTotals.gv.toLocaleString('de-DE')} {renderGrowth(cur?.revenue || 0, yoyTotals.gv)} +
+ ) : ( + Sin datos del año anterior + )} +
+
+ )} - ))} + ); + })} diff --git a/components/mkt/MktDataView.tsx b/components/mkt/MktDataView.tsx index a76e9c5..2c23aaa 100644 --- a/components/mkt/MktDataView.tsx +++ b/components/mkt/MktDataView.tsx @@ -90,9 +90,22 @@ function parseChargebacksExcel(buffer: ArrayBuffer): Map { interface MktDataViewProps { rawData: SalesRecord[]; adsData: AdsRecord[]; + stockMap?: Map; + vendorStockMap?: Map; + top50Mode?: 'eu' | 'uk'; + velocityMap?: Map; + buyBoxLostMap?: Map }>; } -export default function MktDataView({ rawData, adsData }: MktDataViewProps) { +export default function MktDataView({ + rawData, + adsData, + stockMap, + vendorStockMap, + top50Mode = 'eu', + velocityMap, + buyBoxLostMap +}: MktDataViewProps) { const [includeCOGS, setIncludeCOGS] = useState(true); const [selectedProduct, setSelectedProduct] = useState(null); @@ -314,6 +327,11 @@ export default function MktDataView({ rawData, adsData }: MktDataViewProps) { products={filteredProducts} includeCOGS={includeCOGS} onProductClick={setSelectedProduct} + stockMap={stockMap} + vendorStockMap={vendorStockMap} + top50Mode={top50Mode} + velocityMap={velocityMap} + buyBoxLostMap={buyBoxLostMap} />