diff --git a/App.tsx b/App.tsx index 4d444f6..22e2c05 100644 --- a/App.tsx +++ b/App.tsx @@ -609,7 +609,13 @@ const App: React.FC = () => { )} {view === 'weekly' && ( }> - 0 ? mergeSalesAndAdsData(filteredData, filteredAdsData, undefined, trafficData) : filteredData} onDrillDown={handleSkuDrillDown} stockMap={stockMap} /> + 0 ? mergeSalesAndAdsData(filteredData, filteredAdsData, undefined, trafficData) : filteredData} + onDrillDown={handleSkuDrillDown} + stockMap={stockMap} + stockFilter={filters.stock} + onStockFilterChange={(s) => setFilters(prev => ({ ...prev, stock: s }))} + /> )} {view === 'movers' && ( @@ -619,12 +625,26 @@ const App: React.FC = () => { )} {view === 'ads' && ( }> - + setFilters(prev => ({ ...prev, stock: s }))} + /> )} {view === 'forecast' && ( }> - + setFilters(prev => ({ ...prev, stock: s }))} + /> )} diff --git a/components/AdsPerformance.tsx b/components/AdsPerformance.tsx index fbeba1d..23c4668 100644 --- a/components/AdsPerformance.tsx +++ b/components/AdsPerformance.tsx @@ -3,6 +3,7 @@ import * as XLSX from 'xlsx'; import { CombinedKPIs, FilterState } from '../types'; import { DownloadIcon } from './Icons'; import { StockBadge } from './StockBadge'; +import { InColumnStockFilter } from './InColumnStockFilter'; interface AdsPerformanceProps { data: CombinedKPIs[]; @@ -12,6 +13,8 @@ interface AdsPerformanceProps { uk: Map; }; stockMap?: Map; + stockFilter: string[]; + onStockFilterChange: (newFilters: string[]) => void; } type SortKey = keyof CombinedKPIs | 'acos' | 'roas' | 'tacos' | 'ctr' | 'cpc' | 'cvrUnits'; @@ -36,7 +39,7 @@ const Top50Badge: React.FC<{ rank: number; label?: string; theme?: 'amber' | 'bl ); }; -const AdsPerformance: React.FC = ({ data, filters, top50Ranking, stockMap }) => { +const AdsPerformance: React.FC = ({ data, filters, top50Ranking, stockMap, stockFilter, onStockFilterChange }) => { const [searchTerm, setSearchTerm] = useState(''); const [showOnlyTop50, setShowOnlyTop50] = useState(false); const [top50Mode, setTop50Mode] = useState<'eu' | 'uk'>('eu'); @@ -368,7 +371,15 @@ const AdsPerformance: React.FC = ({ data, filters, top50Ran - + diff --git a/components/ForecastView.tsx b/components/ForecastView.tsx index d3694c6..f108f44 100644 --- a/components/ForecastView.tsx +++ b/components/ForecastView.tsx @@ -3,6 +3,7 @@ import * as XLSX from 'xlsx'; import { ProductForecastData, FilterState } from '../types'; import { DownloadIcon } from './Icons'; import { StockBadge } from './StockBadge'; +import { InColumnStockFilter } from './InColumnStockFilter'; import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, LineChart, Line, Legend, ComposedChart, Area @@ -16,6 +17,8 @@ interface ForecastViewProps { uk: Map; }; stockMap?: Map; + stockFilter: string[]; + onStockFilterChange: (newFilters: string[]) => void; } const MONTH_ORDER = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; @@ -31,7 +34,7 @@ const Top50Badge: React.FC<{ rank: number; label: string; theme?: 'amber' | 'ind ); }; -const ForecastView: React.FC = ({ data, filters, top50Ranking, stockMap }) => { +const ForecastView: React.FC = ({ data, filters, top50Ranking, stockMap, stockFilter, onStockFilterChange }) => { const [searchTerm, setSearchTerm] = useState(''); const [showOnlyTop50, setShowOnlyTop50] = useState(false); const [top50Mode, setTop50Mode] = useState<'eu' | 'uk'>('eu'); @@ -88,35 +91,31 @@ const ForecastView: React.FC = ({ data, filters, top50Ranking return result; }, [data, filters.line, filters.asin, filters.sku, filters.title]); - // Global Aggregate Data (respects filters) - const globalMonthlyData = useMemo(() => { - return MONTH_ORDER.map(m => { - let forecast = 0; - let actual = 0; - baseFilteredData.forEach(p => { - const monthPoint = p.monthlyData.find(md => md.month === m); - if (monthPoint) { - forecast += monthPoint.forecastUnits; - actual += monthPoint.actualUnits; - } - }); - return { name: m, Forecast: forecast, Actual: actual }; - }); - }, [baseFilteredData]); - - const globalSummary = useMemo(() => { + // 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; baseFilteredData.forEach(p => { - annualForecast += p.annualForecast; - p.monthlyData.forEach(md => { - annualActual += md.actualUnits; + 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 += md.forecastUnits; - periodActual += md.actualUnits; + periodForecast += fc; + periodActual += units; } }); }); @@ -125,15 +124,19 @@ const ForecastView: React.FC = ({ data, filters, top50Ranking const annualFulfillment = annualForecast > 0 ? (annualActual / annualForecast) * 100 : 0; return { - periodForecast, - periodActual, - periodFulfillment, - annualForecast, - annualActual, - annualFulfillment + globalMonthlyData: monthlyStats, + globalSummary: { + periodForecast, + periodActual, + periodFulfillment, + annualForecast, + annualActual, + annualFulfillment + } }; }, [baseFilteredData, activeMonths]); + const filteredProducts = useMemo(() => { let result = baseFilteredData; @@ -357,7 +360,15 @@ const ForecastView: React.FC = ({ data, filters, top50Ranking
PRODUCT +
+ PRODUCT + +
+
- + diff --git a/components/InColumnStockFilter.tsx b/components/InColumnStockFilter.tsx new file mode 100644 index 0000000..088ca6f --- /dev/null +++ b/components/InColumnStockFilter.tsx @@ -0,0 +1,89 @@ +import React, { useState, useRef, useEffect } from 'react'; + +interface InColumnStockFilterProps { + currentFilters: string[]; + onFilterChange: (newFilters: string[]) => void; +} + +const STOCK_OPTIONS = [ + 'Out of Stock (0)', + 'In Stock (>0)', + 'Low Stock (<10)', +]; + +export const InColumnStockFilter: React.FC = ({ currentFilters, onFilterChange }) => { + const [isOpen, setIsOpen] = useState(false); + const containerRef = useRef(null); + + // Close when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const toggleFilter = (option: string) => { + let newFilters = [...currentFilters]; + if (newFilters.includes(option)) { + newFilters = newFilters.filter(f => f !== option); + } else { + newFilters.push(option); + } + onFilterChange(newFilters); + }; + + const clearFilters = () => { + onFilterChange([]); + setIsOpen(false); + }; + + return ( +
+ + + {isOpen && ( +
+
+ Stock Filter + {currentFilters.length > 0 && ( + + )} +
+ {STOCK_OPTIONS.map(option => { + const isSelected = currentFilters.includes(option); + return ( +
{ + e.stopPropagation(); + toggleFilter(option); + }} + className={`flex items-center gap-2 px-2 py-1.5 rounded-lg cursor-pointer transition-colors ${isSelected ? 'bg-indigo-500/20 text-indigo-300' : 'text-slate-300 hover:bg-white/5'}`} + > +
+ {isSelected && } +
+ {option} +
+ ); + })} +
+ )} +
+ ); +}; diff --git a/components/WeeklyGrid.tsx b/components/WeeklyGrid.tsx index f79e203..0994f04 100644 --- a/components/WeeklyGrid.tsx +++ b/components/WeeklyGrid.tsx @@ -3,6 +3,7 @@ import * as XLSX from 'xlsx'; import { CombinedKPIs } from '../types'; import { pivotWeeklySalesData, WeeklyPivotRow } from '../services/dataProcessor'; import { StockBadge } from './StockBadge'; +import { InColumnStockFilter } from './InColumnStockFilter'; interface WeeklyGridProps { data: CombinedKPIs[]; @@ -12,6 +13,8 @@ interface WeeklyGridProps { }; onDrillDown?: (sku: string) => void; stockMap?: Map; + stockFilter: string[]; + onStockFilterChange: (newFilters: string[]) => void; } type SortConfig = { @@ -53,7 +56,7 @@ const Top50Badge: React.FC<{ rank: number; label?: string; theme?: 'amber' | 'bl ); }; -const WeeklyGrid: React.FC = ({ data, top50Ranking, onDrillDown, stockMap }) => { +const WeeklyGrid: React.FC = ({ data, top50Ranking, onDrillDown, stockMap, stockFilter, onStockFilterChange }) => { // Pivot data - memoized const { rows, weeks: allWeeks } = useMemo(() => pivotWeeklySalesData(data), [data]); @@ -408,6 +411,10 @@ const WeeklyGrid: React.FC = ({ data, top50Ranking, onDrillDown >
Product Details + {sortConfig?.metric === 'rank' && ( {sortConfig.direction === 'asc' ? '↑' : '↓'}
Product Info +
+ Product Info + +
+
Forecast (Period) Actual Units Sales 2026 Fc 26 Achievement