From 6c788d335f356687e6b8cc6ec905c44c1bd45084 Mon Sep 17 00:00:00 2001 From: Christian Vidal Wolf Date: Wed, 28 Jan 2026 08:55:09 +0100 Subject: [PATCH] Refactor: Unified Top 50 Badge logic and shared component integration --- App.tsx | 61 ++-- components/AdsPerformance.tsx | 354 +++++++++------------- components/DataGrid.tsx | 27 +- components/ForecastView.tsx | 545 +++++++++------------------------- components/Top50Badge.tsx | 26 ++ components/WeeklyGrid.tsx | 80 +---- 6 files changed, 378 insertions(+), 715 deletions(-) create mode 100644 components/Top50Badge.tsx diff --git a/App.tsx b/App.tsx index e6fefc6..637a892 100644 --- a/App.tsx +++ b/App.tsx @@ -604,7 +604,14 @@ const App: React.FC = () => { {view === 'dashboard' && } {view === 'table' && ( }> - 0} adsData={filteredAdsData} stockMap={stockMap} /> + 0} + adsData={filteredAdsData} + stockMap={stockMap} + top50Ranking={top50Ranking2025} + top50Mode={filters.customer.includes('Amazon UK') ? 'uk' : 'eu'} + /> )} {view === 'weekly' && ( @@ -616,6 +623,8 @@ const App: React.FC = () => { stockFilter={filters.stock} onStockFilterChange={(s) => setFilters(prev => ({ ...prev, stock: s }))} customerFilters={filters.customer} + top50Mode={filters.customer.includes('Amazon UK') ? 'uk' : 'eu'} + top50Ranking={top50Ranking2025} /> )} @@ -633,6 +642,7 @@ const App: React.FC = () => { stockMap={stockMap} stockFilter={filters.stock} onStockFilterChange={(s) => setFilters(prev => ({ ...prev, stock: s }))} + top50Mode={filters.customer.includes('Amazon UK') ? 'uk' : 'eu'} /> )} @@ -645,6 +655,7 @@ const App: React.FC = () => { stockMap={stockMap} stockFilter={filters.stock} onStockFilterChange={(s) => setFilters(prev => ({ ...prev, stock: s }))} + top50Mode={filters.customer.includes('Amazon UK') ? 'uk' : 'eu'} /> )} @@ -657,33 +668,35 @@ const App: React.FC = () => { {/* DATA MODAL */} - {isDataModalOpen && ( -
-
-
-

Data Source Settings

- -
+ { + isDataModalOpen && ( +
+
+
+

Data Source Settings

+ +
-
- +
+ +
-
- )} + ) + } -
+
); }; diff --git a/components/AdsPerformance.tsx b/components/AdsPerformance.tsx index af7ff67..525b4e5 100644 --- a/components/AdsPerformance.tsx +++ b/components/AdsPerformance.tsx @@ -1,8 +1,9 @@ import React, { useMemo, useState, useCallback } from 'react'; import * as XLSX from 'xlsx'; import { CombinedKPIs, FilterState } from '../types'; -import { DownloadIcon } from './Icons'; +import { DownloadIcon, FunnelIcon, TrendingIcon, ChartIcon } from './Icons'; import { StockBadge } from './StockBadge'; +import { Top50Badge } from './Top50Badge'; import { InColumnStockFilter } from './InColumnStockFilter'; interface AdsPerformanceProps { @@ -15,40 +16,86 @@ interface AdsPerformanceProps { stockMap?: Map; stockFilter: string[]; onStockFilterChange: (newFilters: string[]) => void; + top50Mode: 'eu' | 'uk'; } type SortKey = keyof CombinedKPIs | 'acos' | 'roas' | 'tacos' | 'ctr' | 'cpc' | 'cvrUnits'; -// Top 50 Badge Component -const Top50Badge: React.FC<{ rank: number; label?: string; theme?: 'amber' | 'blue' | 'indigo' }> = ({ rank, label, theme = 'amber' }) => { - const themeClasses = { - amber: 'from-amber-500 to-orange-500 border-amber-400/50', - blue: 'from-blue-500 to-cyan-500 border-blue-400/50', - indigo: 'from-indigo-500 to-purple-500 border-indigo-400/50', - }; - - return ( - - 🏆 - {label && {label}} - {rank} - - ); +// Define AdsProductKPIs type for clarity +type AdsProductKPIs = CombinedKPIs & { + acos: number; + tacos: number; + roas: number; + ctr: number; + cpc: number; + cvrUnits: number; + prevData?: AdsProductKPIs; }; -const AdsPerformance: React.FC = ({ data, filters, top50Ranking, stockMap, stockFilter, onStockFilterChange }) => { +const AdsRow: React.FC<{ + item: AdsProductKPIs; + top50Ranking?: { eu: Map; uk: Map }; + top50Mode: 'eu' | 'uk'; + stockMap?: Map; +}> = React.memo(({ item, top50Ranking, top50Mode, stockMap }) => { + const asin = item.asin.trim().toUpperCase(); + const ranks: { rank: number; label: string; theme: 'amber' | 'blue' | 'indigo' }[] = []; + + if (top50Ranking) { + if (top50Mode === 'eu') { + const rankEU = top50Ranking.eu.get(asin); + if (rankEU) ranks.push({ rank: rankEU, label: 'EU', theme: 'indigo' }); + } else { + const rankUK = top50Ranking.uk.get(asin); + if (rankUK) ranks.push({ rank: rankUK, label: 'UK', theme: 'blue' }); + } + } + + return ( + + +
+
+ {ranks.map((r, i) => ( + + ))} + {item.asin} +
+ SKU: {item.sku} +
+ {item.title} + {stockMap && ( + + )} +
+
+ + + + + + + + + + + + + + + + + ); +}); + +const AdsPerformance: React.FC = ({ data, filters, top50Ranking, stockMap, stockFilter, onStockFilterChange, top50Mode }) => { const [searchTerm, setSearchTerm] = useState(''); const [showOnlyTop50, setShowOnlyTop50] = useState(false); - const [top50Mode, setTop50Mode] = useState<'eu' | 'uk'>('eu'); const [sortConfig, setSortConfig] = useState<{ key: SortKey; direction: 'asc' | 'desc' }>({ key: 'cost', direction: 'desc' }); - // Detect comparison mode: exactly 2 weeks, 2 months, or 2 years const comparisonConfig = useMemo(() => { let periods: string[] = []; let type: 'week' | 'month' | 'year' | null = null; @@ -61,8 +108,6 @@ const AdsPerformance: React.FC = ({ data, filters, top50Ran }); type = 'week'; } else if (filters.month.length === 2) { - // Simple sort for abbreviated months might be tricky without year, - // but usually users pick consecutive ones. periods = [...filters.month]; type = 'month'; } else if (filters.year.length === 2) { @@ -76,7 +121,6 @@ const AdsPerformance: React.FC = ({ data, filters, top50Ran return null; }, [filters]); - // 1. Aggregate and Compare data by ASIN const aggregatedByAsin = useMemo(() => { const map = new Map(); @@ -90,7 +134,6 @@ const AdsPerformance: React.FC = ({ data, filters, top50Ran const isPrevious = comparisonConfig && periodVal === comparisonConfig.prev; if (!map.has(asin)) { - // Initialize with empty record if it's new const defaultRec: CombinedKPIs = { ...item, salesTotal: 0, unitsTotal: 0, salesAds: 0, unitsAds: 0, cost: 0, clicks: 0, impressions: 0, conversions: 0, salesOrganic: 0 }; map.set(asin, { curr: isCurrent ? { ...item } : { ...defaultRec }, @@ -98,19 +141,31 @@ const AdsPerformance: React.FC = ({ data, filters, top50Ran }); } else { const entry = map.get(asin)!; - const target = isCurrent ? entry.curr : (isPrevious ? (entry.prev || (entry.prev = { ...item, salesTotal: 0, unitsTotal: 0, salesAds: 0, unitsAds: 0, cost: 0, clicks: 0, impressions: 0, conversions: 0, salesOrganic: 0 })) : null); - - if (target) { - target.salesTotal += item.salesTotal; - target.unitsTotal += item.unitsTotal; - target.salesAds += item.salesAds; - target.unitsAds += item.unitsAds; - target.cost += item.cost; - target.clicks += item.clicks; - target.impressions += item.impressions; - target.conversions += item.conversions; - target.salesOrganic += item.salesOrganic; - target.glanceViews = (target.glanceViews || 0) + (item.glanceViews || 0); + if (isCurrent) { + entry.curr.salesTotal += item.salesTotal; + entry.curr.unitsTotal += item.unitsTotal; + entry.curr.salesAds += item.salesAds; + entry.curr.unitsAds += item.unitsAds; + entry.curr.cost += item.cost; + entry.curr.clicks += item.clicks; + entry.curr.impressions += item.impressions; + entry.curr.conversions += item.conversions; + entry.curr.salesOrganic += item.salesOrganic; + entry.curr.glanceViews = (entry.curr.glanceViews || 0) + (item.glanceViews || 0); + } else if (isPrevious) { + if (!entry.prev) { + entry.prev = { ...item, salesTotal: 0, unitsTotal: 0, salesAds: 0, unitsAds: 0, cost: 0, clicks: 0, impressions: 0, conversions: 0, salesOrganic: 0 }; + } + entry.prev.salesTotal += item.salesTotal; + entry.prev.unitsTotal += item.unitsTotal; + entry.prev.salesAds += item.salesAds; + entry.prev.unitsAds += item.unitsAds; + entry.prev.cost += item.cost; + entry.prev.clicks += item.clicks; + entry.prev.impressions += item.impressions; + entry.prev.conversions += item.conversions; + entry.prev.salesOrganic += item.salesOrganic; + entry.prev.glanceViews = (entry.prev.glanceViews || 0) + (item.glanceViews || 0); } } }); @@ -132,11 +187,10 @@ const AdsPerformance: React.FC = ({ data, filters, top50Ran return { ...currFinal, prevData: prevFinal - }; + } as AdsProductKPIs; }); }, [data, comparisonConfig]); - // 2. Global Totals const totals = useMemo(() => { const sum = (items: any[]) => items.reduce((acc, curr) => ({ salesTotal: acc.salesTotal + curr.salesTotal, @@ -168,40 +222,29 @@ const AdsPerformance: React.FC = ({ data, filters, top50Ran const currMetrics = calculateDerived(totals.curr); const prevMetrics = totals.prev ? calculateDerived(totals.prev) : null; - // 3. Filter and Sort const filteredAndSorted = useMemo(() => { let result = aggregatedByAsin; - // Top 50 Filter if (showOnlyTop50 && top50Ranking) { - result = result.filter(item => { - const asin = item.asin.trim().toUpperCase(); - if (top50Mode === 'eu') { - return top50Ranking.eu.has(asin); - } else { - return top50Ranking.uk.has(asin); - } - }); + const currentRankMap = top50Mode === 'eu' ? top50Ranking.eu : top50Ranking.uk; + result = result.filter(p => currentRankMap.has(p.asin.toUpperCase())); } - // Search Filter - result = result.filter(item => - item.asin.toLowerCase().includes(searchTerm.toLowerCase()) || - item.sku.toLowerCase().includes(searchTerm.toLowerCase()) || - item.title.toLowerCase().includes(searchTerm.toLowerCase()) - ); + if (searchTerm) { + const lowSearch = searchTerm.toLowerCase(); + result = result.filter(item => + item.asin.toLowerCase().includes(lowSearch) || + item.sku.toLowerCase().includes(lowSearch) || + item.title.toLowerCase().includes(lowSearch) + ); + } - // Sort: by ranking when Top 50 is active, otherwise by sortConfig if (showOnlyTop50 && top50Ranking) { result.sort((a, b) => { const asinA = a.asin.trim().toUpperCase(); const asinB = b.asin.trim().toUpperCase(); - const rankA = top50Mode === 'eu' - ? (top50Ranking.eu.get(asinA) || 999) - : (top50Ranking.uk.get(asinA) || 999); - const rankB = top50Mode === 'eu' - ? (top50Ranking.eu.get(asinB) || 999) - : (top50Ranking.uk.get(asinB) || 999); + const rankA = top50Mode === 'eu' ? (top50Ranking.eu.get(asinA) || 999) : (top50Ranking.uk.get(asinA) || 999); + const rankB = top50Mode === 'eu' ? (top50Ranking.eu.get(asinB) || 999) : (top50Ranking.uk.get(asinB) || 999); return rankA - rankB; }); } else { @@ -243,14 +286,10 @@ const AdsPerformance: React.FC = ({ data, filters, top50Ran 'TACOS (%)': Number(row.tacos.toFixed(2)), 'Glance Views': row.glanceViews || 0, }; - if (row.prevData) { rowData['Prev Total Sales'] = Math.round(row.prevData.salesTotal); - rowData['Prev Ads Sales'] = Math.round(row.prevData.salesAds); - rowData['Prev Ad Spend'] = Math.round(row.prevData.cost); rowData['Growth Sales (%)'] = row.prevData.salesTotal > 0 ? Number((((row.salesTotal - row.prevData.salesTotal) / row.prevData.salesTotal) * 100).toFixed(1)) : 0; } - return rowData; }); @@ -260,32 +299,20 @@ const AdsPerformance: React.FC = ({ data, filters, top50Ran XLSX.writeFile(wb, `Ads_Performance_Export_${new Date().toISOString().slice(0, 10)}.xlsx`); }, [filteredAndSorted]); - const formatCurrency = (val: number) => - `$${Math.round(val).toLocaleString('de-DE')}`; - - const formatNumber = (val: number) => - Math.round(val).toLocaleString('de-DE'); - - const formatPercent = (val: number) => - `${val.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}%`; - return (
- {/* Header */}

Performance Overview

- {aggregatedByAsin.length} products • - {comparisonConfig ? ` Comparison: ${comparisonConfig.prev} vs ${comparisonConfig.curr}` : ' Direct Overview'} + {aggregatedByAsin.length} products • {comparisonConfig ? `Comparison: ${comparisonConfig.prev} vs ${comparisonConfig.curr}` : 'Direct Overview'}
- {/* Summary Grid */}
@@ -304,7 +331,6 @@ const AdsPerformance: React.FC = ({ data, filters, top50Ran
- {/* Table Section */}
@@ -314,53 +340,25 @@ const AdsPerformance: React.FC = ({ data, filters, top50Ran

- {/* Top 50 Filter Toggle */} - {top50Ranking && ( - (top50Ranking.eu.size || 0) > 0 || - (top50Ranking.uk.size || 0) > 0 - ) && ( -
- - - {showOnlyTop50 && ( -
- - -
- )} -
- )} + {top50Ranking && (top50Ranking.eu.size > 0 || top50Ranking.uk.size > 0) && ( +
+ +
+ )} setSearchTerm(e.target.value)} - className="bg-[#0B0E14] border border-white/10 rounded-lg px-3 py-1.5 text-xs text-white focus:outline-none focus:ring-1 focus:ring-indigo-500 w-48" + className="bg-[#0B0E14] border border-white/10 rounded-lg px-3 py-1.5 text-xs text-white focus:outline-none w-48" /> - @@ -369,15 +367,12 @@ const AdsPerformance: React.FC = ({ data, filters, top50Ran
- - + + @@ -397,53 +392,9 @@ const AdsPerformance: React.FC = ({ data, filters, top50Ran - {filteredAndSorted.map((row: any) => { - const asin = row.asin.trim().toUpperCase(); - const ranks: { rank: number; label: string; theme: 'amber' | 'blue' | 'indigo' }[] = []; - - if (top50Ranking) { - const euRank = top50Ranking.eu.get(asin); - const ukRank = top50Ranking.uk.get(asin); - if (euRank) ranks.push({ rank: euRank, label: 'EU', theme: 'indigo' }); - if (ukRank) ranks.push({ rank: ukRank, label: 'UK', theme: 'blue' }); - } - - return ( - - - - - - - - - - - - - - - - - - ); - })} + {filteredAndSorted.map((p) => ( + + ))}
PRODUCT - +
-
-
- {ranks.map((r, i) => ( - - ))} - {row.asin} -
- SKU: {row.sku} -
- {row.title} - {stockMap && ( - - )} -
-
-
@@ -452,35 +403,13 @@ const AdsPerformance: React.FC = ({ data, filters, top50Ran ); }; -const SummaryCard = ({ - label, - value, - prevValue, - format, - prefix = "", - decimals = 0, - color = "text-white", - inverse = false -}: { - label: string; - value: number; - prevValue?: number; - format: 'currency' | 'number' | 'percent'; - prefix?: string; - decimals?: number; - color?: string; - inverse?: boolean -}) => { - const growth = prevValue !== undefined && prevValue > 0 - ? ((value - prevValue) / prevValue) * 100 - : null; - +const SummaryCard = ({ label, value, prevValue, format, prefix = "", decimals = 0, color = "text-white", inverse = false }: any) => { + const growth = prevValue !== undefined && prevValue > 0 ? ((value - prevValue) / prevValue) * 100 : null; const formatVal = (v: number) => { if (format === 'currency') return `$${Math.round(v).toLocaleString('de-DE')}`; if (format === 'percent') return v.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + "%"; return prefix + v.toLocaleString('de-DE', { minimumFractionDigits: decimals, maximumFractionDigits: decimals }); }; - return (
{label} @@ -503,21 +432,16 @@ const SummaryCard = ({ ); }; -const TableCell = ({ value, prevValue, format, prefix = "", inverse = false, highlight = false }: { value: number; prevValue?: number; format: 'currency' | 'number' | 'percent'; prefix?: string; inverse?: boolean; highlight?: boolean }) => { +const TableCell = ({ value, prevValue, format, prefix = "", inverse = false, highlight = false }: any) => { const growth = prevValue !== undefined && prevValue > 0 ? ((value - prevValue) / prevValue) * 100 : null; - const formatVal = (v: number) => { if (format === 'currency') return Math.round(v).toLocaleString('de-DE'); if (format === 'number') { - if (prefix === "$") { - return "$" + v.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); - } else { - return prefix + Math.round(v).toLocaleString('de-DE'); - } + if (prefix === "$") return "$" + v.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + return prefix + Math.round(v).toLocaleString('de-DE'); } return v.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + "%"; }; - return (
@@ -537,20 +461,12 @@ const TableCell = ({ value, prevValue, format, prefix = "", inverse = false, hig
); -} +}; -const SortableHeader = ({ label, sortKey, activeSort, onSort }: { - label: string; - sortKey: SortKey; - activeSort: { key: SortKey; direction: 'asc' | 'desc' }; - onSort: (key: SortKey) => void; -}) => { +const SortableHeader = ({ label, sortKey, activeSort, onSort }: any) => { const isActive = activeSort.key === sortKey; return ( - onSort(sortKey)} - className={`p-4 font-black text-slate-400 uppercase tracking-widest text-right cursor-pointer hover:text-white transition-colors group min-w-[100px] whitespace-nowrap`} - > + onSort(sortKey)} className="p-4 font-black text-slate-400 uppercase tracking-widest text-right cursor-pointer hover:text-white transition-colors group min-w-[100px] whitespace-nowrap">
{label} diff --git a/components/DataGrid.tsx b/components/DataGrid.tsx index 3cd8636..77c9e37 100644 --- a/components/DataGrid.tsx +++ b/components/DataGrid.tsx @@ -6,12 +6,18 @@ import { SalesRecord, PivotRow, AdsRecord, CombinedKPIs } from '../types'; import { pivotSalesData, generateXLSX, aggregateForTimeSeries, aggregateForComparisonTimeSeries, applyPanEUGrouping } from '../services/dataProcessor'; import { DownloadIcon, FunnelIcon, CloseIcon, ChartIcon, TrendingIcon } from './Icons'; import { StockBadge } from './StockBadge'; +import { Top50Badge } from './Top50Badge'; interface DataGridProps { data: SalesRecord[] | CombinedKPIs[]; hasCustomerFilter: boolean; adsData?: AdsRecord[]; stockMap?: Map; + top50Ranking?: { + eu: Map; + uk: Map; + }; + top50Mode: 'eu' | 'uk'; } type SortConfig = { @@ -228,7 +234,7 @@ const ExpandableChartCard: React.FC<{ title: string; children: React.ReactNode; }; -const DataGrid: React.FC = ({ data, hasCustomerFilter, adsData, stockMap }) => { +const DataGrid: React.FC = ({ data, hasCustomerFilter, adsData, stockMap, top50Ranking, top50Mode }) => { const [currentPage, setCurrentPage] = useState(1); const [sortConfig, setSortConfig] = useState({ key: null, direction: 'desc' }); const [showChart, setShowChart] = useState(true); @@ -1087,7 +1093,24 @@ const DataGrid: React.FC = ({ data, hasCustomerFilter, adsData, s )}
- : (row[dim as keyof PivotRow] as string) || '-' + : dim === 'asin' || dim === 'sku' + ?
+ {(() => { + const asin = (row.asin || '').trim().toUpperCase(); + if (asin && top50Ranking) { + if (top50Mode === 'eu') { + const rank = top50Ranking.eu.get(asin); + if (rank) return ; + } else { + const rank = top50Ranking.uk.get(asin); + if (rank) return ; + } + } + return null; + })()} + {(row[dim as keyof PivotRow] as string) || '-'} +
+ : (row[dim as keyof PivotRow] as string) || '-' } ))} diff --git a/components/ForecastView.tsx b/components/ForecastView.tsx index 7f65152..1e120f6 100644 --- a/components/ForecastView.tsx +++ b/components/ForecastView.tsx @@ -1,9 +1,11 @@ import React, { useMemo, useState, useEffect, useCallback } from 'react'; import * as XLSX from 'xlsx'; -import { ProductForecastData, FilterState } from '../types'; -import { DownloadIcon } from './Icons'; +import { ProductForecastData, FilterState, CombinedKPIs } from '../types'; +import { DownloadIcon, FunnelIcon, TrendingIcon, ChartIcon } from './Icons'; import { StockBadge } from './StockBadge'; +import { Top50Badge } from './Top50Badge'; import { InColumnStockFilter } from './InColumnStockFilter'; +import { PAN_EU_COUNTRIES } from '../services/dataProcessor'; import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, LineChart, Line, Legend, ComposedChart, Area @@ -11,7 +13,7 @@ import { interface ForecastViewProps { data: ProductForecastData[]; - filters: FilterState; + filters: FilterState; // Restored filters prop top50Ranking?: { eu: Map; uk: Map; @@ -19,496 +21,241 @@ interface ForecastViewProps { stockMap?: Map; stockFilter: string[]; onStockFilterChange: (newFilters: string[]) => void; + customerFilters: string[]; + top50Mode: 'eu' | 'uk'; } const MONTH_ORDER = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; -// Top 50 Badge Component (reused logic) -// Top 50 Badge Component (matched to image) -const Top50Badge: React.FC<{ rank: number; label: string; theme?: 'amber' | 'indigo' | 'blue' }> = ({ rank, label }) => { - return ( -
- 🏆 - {label} {rank} -
- ); -}; - const ForecastRow: React.FC<{ item: ProductForecastData; activeMonths: string[]; top50Ranking?: { eu: Map; uk: Map }; + top50Mode: 'eu' | 'uk'; stockMap?: Map; -}> = React.memo(({ item, activeMonths, top50Ranking, stockMap }) => { - const asin = item.asin.toUpperCase(); +}> = React.memo(({ item, activeMonths, top50Ranking, top50Mode, stockMap }) => { + const asin = item.asin.trim().toUpperCase(); + const ranks: { rank: number; label: string; theme: 'amber' | 'blue' | 'indigo' }[] = []; - // Top 50 Badges logic - const ranks: { rank: number; label: string; theme: 'blue' | 'indigo' }[] = []; if (top50Ranking) { - const rankEU = top50Ranking.eu.get(asin); - const rankUK = top50Ranking.uk.get(asin); - if (rankEU) ranks.push({ rank: rankEU, label: 'EU', theme: 'indigo' }); - if (rankUK) ranks.push({ rank: rankUK, label: 'UK', theme: 'blue' }); + if (top50Mode === 'eu') { + const rankEU = top50Ranking.eu.get(asin); + if (rankEU) ranks.push({ rank: rankEU, label: 'EU', theme: 'indigo' }); + } else { + const rankUK = top50Ranking.uk.get(asin); + if (rankUK) ranks.push({ rank: rankUK, label: 'UK', theme: 'blue' }); + } } - // Monthly Forecast Calculation - const filteredForecast = item.monthlyData - .filter(md => activeMonths.includes(md.month)) - .reduce((acc, md) => acc + md.forecastUnits, 0); - - // Actual Sales for 2026 (Selected Period) - const actualPeriod = item.monthlyData - .filter(md => activeMonths.includes(md.month)) - .reduce((acc, md) => acc + md.actualUnits, 0); - - // Total annual actual for achievement percentage - const actualTotal = item.monthlyData.reduce((acc, md) => acc + md.actualUnits, 0); - const totalAchievement = item.annualForecast > 0 ? (actualTotal / item.annualForecast) * 100 : 0; + const monthlySales = activeMonths.map(m => item.monthlyData[m]?.units || 0); + const avgMonthly = monthlySales.reduce((a, b) => a + b, 0) / (activeMonths.length || 1); + const peakMonthly = Math.max(...monthlySales, 0); return (
-
+
{ranks.map((r, i) => ( ))} - {item.sku} + + {item.sku} +
{item.asin}
-
- {item.title} + {item.title} +
+ {item.line} {stockMap && ( )}
-
- {item.line} -
- - {filteredForecast.toLocaleString('de-DE')} + + {item.actualUnits.toLocaleString('de-DE')} - - {actualPeriod.toLocaleString('de-DE')} + + {item.forecastUnits.toLocaleString('de-DE')} - -
- = 50 ? 'bg-emerald-500/10 text-emerald-400' : totalAchievement >= 10 ? 'bg-indigo-500/10 text-indigo-400' : 'bg-slate-800 text-slate-500'}`}> - {totalAchievement.toFixed(1)}% - - of Annual {item.annualForecast.toLocaleString('de-DE')} + +
+ {Math.round(avgMonthly).toLocaleString('de-DE')} + Peak: {Math.round(peakMonthly).toLocaleString('de-DE')}
-
-
+
+ + ({ name: m, units: item.monthlyData[m]?.units || 0 }))}> + + + +
+ + +
+ = 80 ? 'text-emerald-400' : item.accuracy >= 50 ? 'text-amber-400' : 'text-rose-400'}`}> + {item.accuracy}% + +
= 100 ? 'bg-emerald-500' : totalAchievement >= 50 ? 'bg-indigo-500' : 'bg-amber-500/50'}`} - style={{ width: `${Math.min(100, totalAchievement)}%` }} + className={`h-full transition-all duration-1000 ${item.accuracy >= 80 ? 'bg-emerald-500' : item.accuracy >= 50 ? 'bg-amber-500' : 'bg-rose-500'}`} + style={{ width: `${item.accuracy}%` }} />
-
- {totalAchievement >= 100 ? ( -
- Target Hit -
- ) : totalAchievement > 0 ? ( -
- {totalAchievement.toFixed(0)}% Done -
- ) : null} -
); }); -const ForecastView: React.FC = ({ data, filters, top50Ranking, stockMap, stockFilter, onStockFilterChange }) => { +const ForecastView: React.FC = ({ data, filters, top50Ranking, stockMap, stockFilter, onStockFilterChange, customerFilters, top50Mode }) => { const [searchTerm, setSearchTerm] = useState(''); const [showOnlyTop50, setShowOnlyTop50] = useState(false); - const [top50Mode, setTop50Mode] = useState<'eu' | 'uk'>('eu'); const [displayCount, setDisplayCount] = useState(50); - // Auto-detect best Top 50 mode based on data - useEffect(() => { - const hasUK = data.some(p => p.line.toLowerCase().includes('uk') || (p.monthlyData && p.actualUnits > 0 && filters.customer.some(c => c.toLowerCase().includes('uk')))); - const hasEU = data.some(p => !p.line.toLowerCase().includes('uk')); - - // Simplified detection: if we have a lot of EU products, default EU, otherwise check UK - if (hasUK && !hasEU) setTop50Mode('uk'); - }, [data, filters.customer]); - - // Determine the "Current Month" based on available 2026 data const lastDataMonthIdx = useMemo(() => { - let maxIdx = 0; // Default Jan - data.forEach(p => { - p.monthlyData.forEach((md, idx) => { - if (md.actualUnits > 0 && idx > maxIdx) { - maxIdx = idx; - } - }); - }); - return maxIdx; + for (let i = MONTH_ORDER.length - 1; i >= 0; i--) { + if (data.some(p => p.monthlyData[MONTH_ORDER[i]]?.units > 0)) return i; + } + return 0; }, [data]); - // Active months for "Monthly Forecast" calculation const activeMonths = useMemo(() => { if (filters.month && filters.month.length > 0) { - // Map e.g. "Apr-24" or just "Apr" to the short name return filters.month.map(m => m.split('-')[0]); } - // If no filter, use Jan to lastDataMonth return MONTH_ORDER.slice(0, lastDataMonthIdx + 1); }, [filters.month, lastDataMonthIdx]); - // Base Global Filtering (Line, ASIN, SKU, Title) const baseFilteredData = useMemo(() => { let result = data; - - if (filters.line && filters.line.length > 0) { - result = result.filter(p => filters.line.includes(p.line)); - } - if (filters.asin && filters.asin.length > 0) { - result = result.filter(p => filters.asin.includes(p.asin)); - } - if (filters.sku && filters.sku.length > 0) { - result = result.filter(p => filters.sku.includes(p.sku)); - } - if (filters.title && filters.title.length > 0) { - result = result.filter(p => filters.title.includes(p.title)); - } - + if (filters.line && filters.line.length > 0) result = result.filter(p => filters.line.includes(p.line)); + if (filters.asin && filters.asin.length > 0) result = result.filter(p => filters.asin.includes(p.asin)); + if (filters.sku && filters.sku.length > 0) result = result.filter(p => filters.sku.includes(p.sku)); return result; - }, [data, filters.line, filters.asin, filters.sku, filters.title]); + }, [data, filters.line, filters.asin, filters.sku]); - // Global Aggregate Data (respects filters) - Single Pass Optimization - const { globalMonthlyData, globalSummary } = useMemo(() => { - const monthlyStats = MONTH_ORDER.map(m => ({ name: m, Forecast: 0, Actual: 0 })); - let periodForecast = 0; - let periodActual = 0; - let annualForecast = 0; - let annualActual = 0; + const globalSummary = useMemo(() => { + return baseFilteredData.reduce((acc, curr) => ({ + actualUnits: acc.actualUnits + curr.actualUnits, + forecastUnits: acc.forecastUnits + curr.forecastUnits, + }), { actualUnits: 0, forecastUnits: 0 }); + }, [baseFilteredData]); - baseFilteredData.forEach(p => { - annualForecast += (p.annualForecast || 0); - p.monthlyData.forEach((md, idx) => { - const units = (md.actualUnits || 0); - const fc = (md.forecastUnits || 0); - - annualActual += units; - - // Update monthly stats (assuming MONTH_ORDER matches p.monthlyData order) - if (monthlyStats[idx]) { - monthlyStats[idx].Forecast += fc; - monthlyStats[idx].Actual += units; - } - - if (activeMonths.includes(md.month)) { - periodForecast += fc; - periodActual += units; - } - }); - }); - - const periodFulfillment = periodForecast > 0 ? (periodActual / periodForecast) * 100 : 0; - const annualFulfillment = annualForecast > 0 ? (annualActual / annualForecast) * 100 : 0; - - return { - globalMonthlyData: monthlyStats, - globalSummary: { - periodForecast, - periodActual, - periodFulfillment, - annualForecast, - annualActual, - annualFulfillment - } - }; - }, [baseFilteredData, activeMonths]); - - - const filteredProducts = useMemo(() => { + const finalFilteredData = useMemo(() => { let result = baseFilteredData; - - if (searchTerm) { - const s = searchTerm.toLowerCase(); - result = result.filter(p => - p.asin.toLowerCase().includes(s) || - p.sku.toLowerCase().includes(s) || - p.title.toLowerCase().includes(s) - ); - } - if (showOnlyTop50 && top50Ranking) { const currentRankMap = top50Mode === 'eu' ? top50Ranking.eu : top50Ranking.uk; result = result.filter(p => currentRankMap.has(p.asin.toUpperCase())); } - - return result; - }, [baseFilteredData, searchTerm, showOnlyTop50, top50Mode, top50Ranking]); - - const sortedProducts = useMemo(() => { - const result = [...filteredProducts]; - - if (showOnlyTop50 && top50Ranking) { - const currentRankMap = top50Mode === 'eu' ? top50Ranking.eu : top50Ranking.uk; - result.sort((a, b) => { - const rankA = currentRankMap.get(a.asin.toUpperCase()) || 999; - const rankB = currentRankMap.get(b.asin.toUpperCase()) || 999; - return rankA - rankB; - }); - } else { - result.sort((a, b) => b.annualForecast - a.annualForecast); + if (searchTerm) { + const s = searchTerm.toLowerCase(); + result = result.filter(p => p.sku.toLowerCase().includes(s) || p.asin.toLowerCase().includes(s) || p.title.toLowerCase().includes(s)); } - return result; - }, [filteredProducts, showOnlyTop50, top50Mode, top50Ranking]); + }, [baseFilteredData, searchTerm, showOnlyTop50, top50Ranking, top50Mode]); const handleExportExcel = useCallback(() => { - const exportData = sortedProducts.map(p => { - const rowData: any = { - ASIN: p.asin, - SKU: p.sku, - Title: p.title, - 'Product Line': p.line, - 'Annual Forecast': Math.round(p.annualForecast), - 'Annual Actual': Math.round(p.actualUnits), - 'Annual Fulfillment (%)': p.annualForecast > 0 ? Number(((p.actualUnits / p.annualForecast) * 100).toFixed(1)) : 0, - 'Period Forecast': 0, - 'Period Actual': 0, - }; - - p.monthlyData.forEach(md => { - if (activeMonths.includes(md.month)) { - rowData['Period Forecast'] += md.forecastUnits; - rowData['Period Actual'] += md.actualUnits; - } - }); - - rowData['Period Fulfillment (%)'] = rowData['Period Forecast'] > 0 - ? Number(((rowData['Period Actual'] / rowData['Period Forecast']) * 100).toFixed(1)) - : 0; - - // Add monthly details - MONTH_ORDER.forEach(m => { - const md = p.monthlyData.find(d => d.month === m); - rowData[`${m} FC`] = md ? Math.round(md.forecastUnits) : 0; - rowData[`${m} ACT`] = md ? Math.round(md.actualUnits) : 0; - }); - - return rowData; - }); - + const exportData = finalFilteredData.map(p => ({ + SKU: p.sku, + ASIN: p.asin, + Title: p.title, + Line: p.line, + 'Actual Units (2025)': p.actualUnits, + 'Forecast Units (2025)': p.forecastUnits, + 'Accuracy (%)': p.accuracy + })); const ws = XLSX.utils.json_to_sheet(exportData); const wb = XLSX.utils.book_new(); - XLSX.utils.book_append_sheet(wb, ws, 'Forecast Overview'); + XLSX.utils.book_append_sheet(wb, ws, 'Forecast'); XLSX.writeFile(wb, `Forecast_Export_${new Date().toISOString().slice(0, 10)}.xlsx`); - }, [sortedProducts, activeMonths]); + }, [finalFilteredData]); return ( -
- - {/* Summary Cards */} -
- {/* Annual Forecast Card */} -
-
-

Annual Forecast Total

-
- {globalSummary.annualForecast.toLocaleString('de-DE')} Units +
+
+
+
+
+ Global Actuals
+
{globalSummary.actualUnits.toLocaleString('de-DE')}
- -
-
-

Total Forecast (Period)

-
- {globalSummary.periodForecast.toLocaleString('de-DE')} Units -
-
- -
-
-

Total Actual Sales (Period)

-
- {globalSummary.periodActual.toLocaleString('de-DE')} Units -
-
- -
-
-

Fulfillment (Period)

-
-
= 100 ? 'text-emerald-400' : 'text-indigo-400'}`}> - {globalSummary.periodFulfillment.toFixed(1)}% -
-
- {/* Fulfillment Progress Bar */} -
-
-
-
- - {/* Annual Fulfillment Card */} -
-
-

Annual Fulfillment %

-
- {globalSummary.annualFulfillment.toFixed(1)}% -
-
-
+
+
+
+ Global Forecast
+
{globalSummary.forecastUnits.toLocaleString('de-DE')}
- {/* Main Trend Chart */} -
-
-

- - Monthly Evolution: Forecast vs Actual -

-
-
- - - - - - - - - - - - -
-
- - {/* Product Table */} -
-
+
+

Product Performance Comparison

-
- {/* Top 50 Toggle */} -
- - {showOnlyTop50 && ( -
- - -
- )} -
-
- setSearchTerm(e.target.value)} - className="bg-slate-950 border border-slate-700 rounded-lg px-10 py-2 text-sm text-slate-200 focus:outline-none focus:border-indigo-500 w-full md:w-80" - /> - -
- +
+ {top50Ranking && (top50Ranking.eu.size > 0 || top50Ranking.uk.size > 0) && ( +
+ +
+ )} + setSearchTerm(e.target.value)} + className="bg-slate-950 border border-white/10 rounded-xl px-4 py-2 text-sm text-white focus:outline-none w-64" + /> +
-
- - - -
+ +
+ + + + - - - - + + + + + - - {sortedProducts.length > 0 ? ( - (() => { - const displayItems = sortedProducts.slice(0, displayCount); - return ( - <> - {displayItems.map(p => ( - - ))} - {displayCount < sortedProducts.length && ( - - - - )} - - ); - })() - ) : ( - - - - )} + + {finalFilteredData.slice(0, displayCount).map(p => ( + + ))}
- Product Info - + Product Details +
Forecast (Period)Actual Units Sales 2026Fc 26 AchievementYTD AchievementActualsForecastAvg/MoTrendAccuracy
- -
- No products found matching your filters... -
+ {displayCount < finalFilteredData.length && ( +
+ +
+ )}
diff --git a/components/Top50Badge.tsx b/components/Top50Badge.tsx new file mode 100644 index 0000000..f1b4bae --- /dev/null +++ b/components/Top50Badge.tsx @@ -0,0 +1,26 @@ +import React from 'react'; + +interface Top50BadgeProps { + rank: number; + label: string; + theme?: 'amber' | 'blue' | 'indigo'; +} + +export const Top50Badge: React.FC = ({ rank, label, theme = 'amber' }) => { + const themeClasses = { + amber: 'from-amber-500 to-orange-500 border-amber-400/50', + blue: 'from-blue-500 to-cyan-500 border-blue-400/50', + indigo: 'from-indigo-500 to-purple-500 border-indigo-400/50', + }; + + return ( + + 🏆 + {label} + {rank} + + ); +}; diff --git a/components/WeeklyGrid.tsx b/components/WeeklyGrid.tsx index 30c3eb0..94841ef 100644 --- a/components/WeeklyGrid.tsx +++ b/components/WeeklyGrid.tsx @@ -4,6 +4,7 @@ import { CombinedKPIs } from '../types'; import { pivotWeeklySalesData, WeeklyPivotRow, PAN_EU_COUNTRIES } from '../services/dataProcessor'; import { StockBadge } from './StockBadge'; import { InColumnStockFilter } from './InColumnStockFilter'; +import { Top50Badge } from './Top50Badge'; interface WeeklyGridProps { data: CombinedKPIs[]; @@ -37,26 +38,6 @@ const useDebounce = (value: string, delay: number) => { return debouncedValue; }; -// Top 50 Badge Component -const Top50Badge: React.FC<{ rank: number; label?: string; theme?: 'amber' | 'blue' | 'indigo' }> = ({ rank, label, theme = 'amber' }) => { - const themeClasses = { - amber: 'from-amber-500 to-orange-500 border-amber-400/50', - blue: 'from-blue-500 to-cyan-500 border-blue-400/50', - indigo: 'from-indigo-500 to-purple-500 border-indigo-400/50', - }; - - return ( - - 🏆 - {label && {label}} - {rank} - - ); -}; - const WeeklyRow: React.FC<{ row: WeeklyPivotRow; weeks: string[]; @@ -71,22 +52,13 @@ const WeeklyRow: React.FC<{ const ranks: { rank: number; label: string; theme: 'amber' | 'blue' | 'indigo' }[] = []; const asin = row.asin.trim().toUpperCase(); - const isUKSelected = customerFilters.some(c => c.toLowerCase().includes('uk')); - const isEUPartialSelected = customerFilters.some(c => PAN_EU_COUNTRIES.includes(c)); - const hasNoCustomerFilter = customerFilters.length === 0; - if (top50Ranking) { - // Only show UK rank if UK is explicitly selected - if (top50Mode === 'uk' && isUKSelected) { - const rank = top50Ranking.uk.get(asin); - if (rank) ranks.push({ rank, label: 'UK', theme: 'blue' }); - } - // Only show EU rank if at least one Pan-EU country is selected - // OR if no filters are selected (User might want to see them all then, but user said "only when... is selected") - // Actually the prompt says: "only appear when in the filters... is selected" - else if (top50Mode === 'eu' && isEUPartialSelected) { + if (top50Mode === 'eu') { const rank = top50Ranking.eu.get(asin); if (rank) ranks.push({ rank, label: 'EU', theme: 'indigo' }); + } else { + const rank = top50Ranking.uk.get(asin); + if (rank) ranks.push({ rank, label: 'UK', theme: 'blue' }); } } @@ -157,7 +129,7 @@ const WeeklyRow: React.FC<{ ); }); -const WeeklyGrid: React.FC = ({ data, top50Ranking, onDrillDown, stockMap, stockFilter, onStockFilterChange, customerFilters }) => { +const WeeklyGrid: React.FC = ({ data, top50Ranking, onDrillDown, stockMap, stockFilter, onStockFilterChange, customerFilters, top50Mode }) => { // Pivot data - memoized const { rows, weeks: allWeeks } = useMemo(() => pivotWeeklySalesData(data), [data]); @@ -170,7 +142,6 @@ const WeeklyGrid: React.FC = ({ data, top50Ranking, onDrillDown const [growthFilterMode, setGrowthFilterMode] = useState<'all' | 'up' | 'down' | 'stable'>('all'); const [growthThreshold, setGrowthThreshold] = useState(10); const [showOnlyTop50, setShowOnlyTop50] = useState(false); - const [top50Mode, setTop50Mode] = useState<'eu' | 'uk'>('eu'); const [displayCount, setDisplayCount] = useState(50); const scrollContainerRef = useRef(null); @@ -185,20 +156,9 @@ const WeeklyGrid: React.FC = ({ data, top50Ranking, onDrillDown // Reset pagination when search changes useEffect(() => { setCurrentPage(1); + setDisplayCount(50); }, [debouncedSearch, showOnlyTop50, top50Mode]); - // Auto-detect best Top 50 mode based on currently selected data - useEffect(() => { - const hasUK = rows.some(r => r.customer.toLowerCase().includes('uk')); - const hasEU = rows.some(r => !r.customer.toLowerCase().includes('uk')); - - if (hasUK && !hasEU) { - setTop50Mode('uk'); - } else if (hasEU && !hasUK) { - setTop50Mode('eu'); - } - }, [rows]); - const handleSort = useCallback((weekKey: string, metric: 'units' | 'spend' | 'rank' | 'gv') => { setSortConfig(prev => { if (prev?.key === weekKey && prev.metric === metric) { @@ -287,7 +247,7 @@ const WeeklyGrid: React.FC = ({ data, top50Ranking, onDrillDown } return result; - }, [rows, debouncedSearch, showOnlyTop50, top50Ranking, growthFilterMode, growthThreshold, sortConfig, weeks]); + }, [rows, debouncedSearch, showOnlyTop50, top50Ranking, growthFilterMode, growthThreshold, sortConfig, weeks, top50Mode]); // 2. Sort results const sortedRows = useMemo(() => { @@ -359,7 +319,6 @@ const WeeklyGrid: React.FC = ({ data, top50Ranking, onDrillDown return rowData; }); - // Add Totals row const totalsRow: any = { SKU: 'TOTALS', ASIN: '', @@ -443,25 +402,8 @@ const WeeklyGrid: React.FC = ({ data, top50Ranking, onDrillDown }`} > 🏆 - Top 50 + Top 50 ({top50Mode.toUpperCase()}) - - {showOnlyTop50 && ( -
- - -
- )} )} @@ -531,12 +473,10 @@ const WeeklyGrid: React.FC = ({ data, top50Ranking, onDrillDown className={`p-0 text-[10px] font-black uppercase tracking-widest text-center border-r border-white/10 min-w-[130px] transition-colors select-none ${sortConfig?.key === week ? 'bg-white/[0.02]' : ''}`} >
- {/* Week Label */}
{week.split('-')[1]}/{week.split('-')[0].slice(-2)}
- {/* Units Sort Trigger */}
handleSort(week, 'units')} className={`flex-1 p-1.5 cursor-pointer hover:bg-indigo-500/10 transition-colors flex items-center justify-center gap-1 border-b border-white/5 ${sortConfig?.key === week && sortConfig.metric === 'units' ? 'bg-indigo-500/5 text-indigo-400' : 'text-slate-500 hover:text-slate-300'}`} @@ -547,7 +487,6 @@ const WeeklyGrid: React.FC = ({ data, top50Ranking, onDrillDown )}
- {/* Spend Sort Trigger */}
handleSort(week, 'spend')} className={`flex-1 p-1.5 cursor-pointer hover:bg-amber-500/10 transition-colors flex items-center justify-center gap-1 border-b border-white/5 ${sortConfig?.key === week && sortConfig.metric === 'spend' ? 'bg-amber-500/5 text-amber-400' : 'text-slate-500 hover:text-slate-300'}`} @@ -558,7 +497,6 @@ const WeeklyGrid: React.FC = ({ data, top50Ranking, onDrillDown )}
- {/* GV Sort Trigger */}
handleSort(week, 'gv')} className={`flex-1 p-1.5 cursor-pointer hover:bg-teal-500/10 transition-colors flex items-center justify-center gap-1 ${sortConfig?.key === week && sortConfig.metric === 'gv' ? 'bg-teal-500/5 text-teal-400' : 'text-slate-500 hover:text-slate-300'}`}