diff --git a/App.tsx b/App.tsx index c311214..a2420ac 100644 --- a/App.tsx +++ b/App.tsx @@ -50,6 +50,8 @@ const App: React.FC = () => { const [lastUpdated, setLastUpdated] = useState(null); const [stockMap, setStockMap] = useState>(new Map()); const [vendorStockMap, setVendorStockMap] = useState>(new Map()); + const [cachedForecastRecords, setCachedForecastRecords] = useState([]); + const [lastForecastFile, setLastForecastFile] = useState(null); // Modal State const [isDataModalOpen, setIsDataModalOpen] = useState(false); @@ -65,6 +67,7 @@ const App: React.FC = () => { week: [], stock: [], vendorStock: [], + woc: [], }); const top50Mode = useMemo(() => { @@ -200,12 +203,20 @@ const App: React.FC = () => { const isUK = activeFilters.customer.includes('Amazon UK'); const filename = isUK ? '/fc UK 26.xlsx' : '/fc 26.xlsx'; - console.log(`[App] Fetching forecast from ${filename}...`); - const response = await fetch(filename); - if (!response.ok) throw new Error(`Forecast file ${filename} not found`); + let fcRecords = cachedForecastRecords; - const buffer = await response.arrayBuffer(); - const fcRecords = await processForecastExcel(buffer); + if (lastForecastFile !== filename || cachedForecastRecords.length === 0) { + console.log(`[App] Fetching fresh forecast from ${filename}...`); + const response = await fetch(filename); + if (!response.ok) throw new Error(`Forecast file ${filename} not found`); + + const buffer = await response.arrayBuffer(); + fcRecords = await processForecastExcel(buffer); + setCachedForecastRecords(fcRecords); + setLastForecastFile(filename); + } else { + console.log(`[App] Using cached forecast for ${filename}`); + } // Build precise metadata map from GLOBAL sales records to ensure info is found even if filtered for market const meta = new Map(); @@ -221,11 +232,11 @@ const App: React.FC = () => { const viewData = calculateForecastViewData(sales, fcRecords, meta, activeFilters, velocityMap); setForecastData(viewData); - console.log('[App] Forecast loaded:', viewData.length, 'records'); + console.log('[App] Forecast calculation complete:', viewData.length, 'records'); } catch (error) { console.warn("Forecast fetch failed:", error); } - }, [velocityMap]); // Added velocityMap to dependencies + }, [velocityMap, cachedForecastRecords, lastForecastFile]); // Update forecast when filters change useEffect(() => { @@ -253,6 +264,7 @@ const App: React.FC = () => { week: [], stock: [], vendorStock: [], + woc: [], }); }; @@ -676,6 +688,8 @@ const App: React.FC = () => { onStockFilterChange={(s) => setFilters(prev => ({ ...prev, stock: s }))} vendorStockFilter={filters.vendorStock} onVendorStockFilterChange={(s) => setFilters(prev => ({ ...prev, vendorStock: s }))} + wocFilter={filters.woc} + onWocFilterChange={(s) => setFilters(prev => ({ ...prev, woc: s }))} customerFilters={filters.customer} top50Mode={filters.customer.includes('Amazon UK') ? 'uk' : 'eu'} top50Ranking={top50Ranking2025} @@ -702,6 +716,8 @@ const App: React.FC = () => { onStockFilterChange={(s) => setFilters(prev => ({ ...prev, stock: s }))} vendorStockFilter={filters.vendorStock} onVendorStockFilterChange={(s) => setFilters(prev => ({ ...prev, vendorStock: s }))} + wocFilter={filters.woc} + onWocFilterChange={(s) => setFilters(prev => ({ ...prev, woc: s }))} top50Mode={filters.customer.includes('Amazon UK') ? 'uk' : 'eu'} /> @@ -719,6 +735,8 @@ const App: React.FC = () => { onStockFilterChange={(s) => setFilters(prev => ({ ...prev, stock: s }))} vendorStockFilter={filters.vendorStock} onVendorStockFilterChange={(s) => setFilters(prev => ({ ...prev, vendorStock: s }))} + wocFilter={filters.woc} + onWocFilterChange={(s) => setFilters(prev => ({ ...prev, woc: s }))} top50Mode={filters.customer.includes('Amazon UK') ? 'uk' : 'eu'} /> diff --git a/Buy_Box_tracker.xlsx b/Buy_Box_tracker.xlsx new file mode 100644 index 0000000..a4d7afd Binary files /dev/null and b/Buy_Box_tracker.xlsx differ diff --git a/components/AdsPerformance.tsx b/components/AdsPerformance.tsx index 47df944..f75971f 100644 --- a/components/AdsPerformance.tsx +++ b/components/AdsPerformance.tsx @@ -21,6 +21,8 @@ interface AdsPerformanceProps { vendorStockMap?: Map; vendorStockFilter: string[]; onVendorStockFilterChange: (newFilters: string[]) => void; + wocFilter: string[]; + onWocFilterChange: (newFilters: string[]) => void; } type SortKey = keyof CombinedKPIs | 'acos' | 'roas' | 'tacos' | 'ctr' | 'cpc' | 'cvrUnits'; @@ -106,6 +108,8 @@ const AdsPerformance: React.FC = ({ onStockFilterChange, vendorStockFilter, onVendorStockFilterChange, + wocFilter, + onWocFilterChange, top50Mode }) => { const [searchTerm, setSearchTerm] = useState(''); @@ -258,6 +262,33 @@ const AdsPerformance: React.FC = ({ ); } + // Apply WOC Filter + if (wocFilter && wocFilter.length > 0 && vendorStockMap) { + result = result.filter(item => { + const asin = item.asin.trim().toUpperCase(); + const stockData = vendorStockMap.get(asin); + if (!stockData) return false; + + const stock = top50Mode === 'uk' ? stockData.uk : stockData.eu; + const velocity = item.avgWeeklySales || 0; + + let woc: number = 0; + if (velocity > 0) { + woc = stock / velocity; + } else if (stock > 0) { + woc = 999; + } + + return wocFilter.some(f => { + if (f === '< 4 Weeks') return woc < 4; + if (f === '> 4 Weeks') return woc >= 4; + if (f === 'Out of Stock') return woc === 0; + if (f === 'Infinite Cover') return woc === 999; + return true; + }); + }); + } + if (showOnlyTop50 && top50Ranking) { result.sort((a, b) => { const asinA = a.asin.trim().toUpperCase(); @@ -408,6 +439,17 @@ const AdsPerformance: React.FC = ({ 'Low Stock (<10)', ]} /> + 4 Weeks', + 'Out of Stock', + 'Infinite Cover' + ]} + /> diff --git a/components/ForecastView.tsx b/components/ForecastView.tsx index 8d80b15..ee60534 100644 --- a/components/ForecastView.tsx +++ b/components/ForecastView.tsx @@ -23,6 +23,8 @@ interface ForecastViewProps { onStockFilterChange: (newFilters: string[]) => void; vendorStockFilter: string[]; onVendorStockFilterChange: (newFilters: string[]) => void; + wocFilter: string[]; + onWocFilterChange: (newFilters: string[]) => void; } const MONTH_ORDER = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; @@ -169,10 +171,20 @@ const ForecastView: React.FC = ({ onStockFilterChange, vendorStockFilter, onVendorStockFilterChange, + wocFilter, + onWocFilterChange, top50Mode }) => { const [searchTerm, setSearchTerm] = useState(''); - const debouncedSearch = useMemo(() => searchTerm.toLowerCase(), [searchTerm]); + const [debouncedSearch, setDebouncedSearch] = useState(''); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedSearch(searchTerm.toLowerCase()); + }, 300); + return () => clearTimeout(timer); + }, [searchTerm]); + const [showOnlyTop50, setShowOnlyTop50] = useState(false); const [displayCount, setDisplayCount] = useState(50); const [sortConfig, setSortConfig] = useState<{ key: string; direction: 'asc' | 'desc' }>({ key: 'actualUnits', direction: 'desc' }); @@ -232,8 +244,8 @@ const ForecastView: React.FC = ({ return Array.from(dataMap.values()); }, [baseFilteredData]); - const processedData = useMemo(() => { - let result = baseFilteredData.map(item => { + const calculatedData = useMemo(() => { + return baseFilteredData.map(item => { const forecastPeriod = activeMonths.reduce((sum, month) => { return sum + (item.monthlyData?.[month]?.forecastUnits || 0); }, 0); @@ -247,6 +259,10 @@ const ForecastView: React.FC = ({ fcAchievement }; }); + }, [baseFilteredData, activeMonths]); + + const processedData = useMemo(() => { + let result = [...calculatedData]; if (showOnlyTop50 && top50Ranking) { const currentRankMap = top50Mode === 'eu' ? top50Ranking.eu : top50Ranking.uk; @@ -261,6 +277,33 @@ const ForecastView: React.FC = ({ ); } + // Apply WOC Filter + if (wocFilter && wocFilter.length > 0 && vendorStockMap) { + result = result.filter(p => { + const asin = p.asin.trim().toUpperCase(); + const stockData = vendorStockMap.get(asin); + if (!stockData) return false; + + const stock = top50Mode === 'uk' ? stockData.uk : stockData.eu; + const velocity = p.avgWeeklySales || 0; + + let woc: number = 0; + if (velocity > 0) { + woc = stock / velocity; + } else if (stock > 0) { + woc = 999; + } + + return wocFilter.some(f => { + if (f === '< 4 Weeks') return woc < 4; + if (f === '> 4 Weeks') return woc >= 4; + if (f === 'Out of Stock') return woc === 0; + if (f === 'Infinite Cover') return woc === 999; + return true; + }); + }); + } + // Apply Sorting const { key, direction } = sortConfig; result.sort((a: any, b: any) => { @@ -276,7 +319,7 @@ const ForecastView: React.FC = ({ }); return result; - }, [baseFilteredData, debouncedSearch, showOnlyTop50, top50Ranking, top50Mode, sortConfig, activeMonths]); + }, [calculatedData, debouncedSearch, showOnlyTop50, top50Ranking, top50Mode, sortConfig]); const paginatedData = useMemo(() => processedData.slice(0, displayCount), [processedData, displayCount]); @@ -393,6 +436,17 @@ const ForecastView: React.FC = ({ className="bg-slate-950 border border-white/10 rounded-xl px-4 py-2 text-sm text-white focus:outline-none w-64" /> + 4 Weeks', + 'Out of Stock', + 'Infinite Cover' + ]} + /> diff --git a/components/WeeklyGrid.tsx b/components/WeeklyGrid.tsx index 990803f..fd06013 100644 --- a/components/WeeklyGrid.tsx +++ b/components/WeeklyGrid.tsx @@ -21,6 +21,8 @@ interface WeeklyGridProps { vendorStockMap?: Map; vendorStockFilter: string[]; onVendorStockFilterChange: (newFilters: string[]) => void; + wocFilter: string[]; + onWocFilterChange: (newFilters: string[]) => void; velocityMap?: Map; } @@ -152,6 +154,8 @@ const WeeklyGrid: React.FC = ({ onStockFilterChange, vendorStockFilter, onVendorStockFilterChange, + wocFilter, + onWocFilterChange, customerFilters, top50Mode, velocityMap @@ -272,8 +276,35 @@ const WeeklyGrid: React.FC = ({ } } + // WOC Filter + if (wocFilter && wocFilter.length > 0 && velocityMap && vendorStockMap) { + result = result.filter(r => { + const asin = r.asin.trim().toUpperCase(); + const stockData = vendorStockMap.get(asin); + if (!stockData) return false; + + const stock = top50Mode === 'uk' ? stockData.uk : stockData.eu; + const velocity = velocityMap.get(asin) || 0; + + let woc: number = 0; + if (velocity > 0) { + woc = stock / velocity; + } else if (stock > 0) { + woc = 999; + } + + return wocFilter.some(f => { + if (f === '< 4 Weeks') return woc < 4; + if (f === '> 4 Weeks') return woc >= 4; + if (f === 'Out of Stock') return woc === 0; + if (f === 'Infinite Cover') return woc === 999; + return true; + }); + }); + } + return result; - }, [rows, debouncedSearch, showOnlyTop50, top50Ranking, growthFilterMode, growthThreshold, sortConfig, weeks, top50Mode]); + }, [rows, debouncedSearch, showOnlyTop50, top50Ranking, growthFilterMode, growthThreshold, sortConfig, weeks, top50Mode, wocFilter, velocityMap, vendorStockMap]); // 2. Sort results const sortedRows = useMemo(() => { @@ -499,6 +530,17 @@ const WeeklyGrid: React.FC = ({ 'Low Stock (<10)', ]} /> + 4 Weeks', + 'Out of Stock', + 'Infinite Cover' + ]} + /> {sortConfig?.metric === 'rank' && ( diff --git a/types.ts b/types.ts index d6c9559..1696ccc 100644 --- a/types.ts +++ b/types.ts @@ -25,6 +25,7 @@ export interface FilterState { week: string[]; // Added Week filter stock: string[]; // Added Stock filter vendorStock: string[]; // Added Vendor Stock filter + woc: string[]; // Added Weeks of Coverage filter } export interface GrowthMetric {