From ac791b52653f72fa88c813f2a2a78b6cf186d875 Mon Sep 17 00:00:00 2001 From: Christian Vidal Wolf Date: Mon, 9 Feb 2026 12:00:39 +0100 Subject: [PATCH] Implement Excel-style filters (SKU, ASIN, Title, Line) across GRID, WEEKLY SALES, and FC26 tabs --- components/DataGrid.tsx | 72 +++++++++-- components/ExcelFilter.tsx | 246 ++++++++++++++++++++++++++++++++++++ components/ForecastView.tsx | 121 +++++++++++++++++- components/WeeklyGrid.tsx | 112 ++++++++++++++-- services/dataProcessor.ts | 41 +++++- types.ts | 10 ++ 6 files changed, 574 insertions(+), 28 deletions(-) create mode 100644 components/ExcelFilter.tsx diff --git a/components/DataGrid.tsx b/components/DataGrid.tsx index de301c1..2c48144 100644 --- a/components/DataGrid.tsx +++ b/components/DataGrid.tsx @@ -2,13 +2,14 @@ import React, { useState, useMemo, useEffect, useRef } from 'react'; import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'; -import { SalesRecord, PivotRow, AdsRecord, CombinedKPIs } from '../types'; -import { pivotSalesData, generateXLSX, aggregateForTimeSeries, aggregateForComparisonTimeSeries, applyPanEUGrouping } from '../services/dataProcessor'; +import { SalesRecord, PivotRow, AdsRecord, CombinedKPIs, ColumnFilterCondition } from '../types'; +import { pivotSalesData, generateXLSX, aggregateForTimeSeries, aggregateForComparisonTimeSeries, applyPanEUGrouping, filterData } from '../services/dataProcessor'; import { DownloadIcon, FunnelIcon, CloseIcon, ChartIcon, TrendingIcon } from './Icons'; import { StockBadge } from './StockBadge'; import { Top50Badge } from './Top50Badge'; import { VendorStockBadge } from './VendorStockBadge'; import { BuyBoxWarningBadge } from './BuyBoxWarningBadge'; +import { ExcelFilter } from './ExcelFilter'; interface DataGridProps { data: SalesRecord[] | CombinedKPIs[]; @@ -297,6 +298,19 @@ const DataGrid: React.FC = ({ data, hasCustomerFilter, adsData, s const [newFilterOperator, setNewFilterOperator] = useState<'gt' | 'lt'>('gt'); const [newFilterValue, setNewFilterValue] = useState(''); + // State for Column Filters + const [columnFilters, setColumnFilters] = useState>({}); + + const handleColumnFilterChange = (columnKey: string, condition: ColumnFilterCondition | undefined) => { + setColumnFilters(prev => { + const next = { ...prev }; + if (condition) next[columnKey] = condition; + else delete next[columnKey]; + return next; + }); + setCurrentPage(1); + }; + // Effective dimensions for rendering const effectiveDimensions = useMemo(() => selectedDimensions.length > 0 ? selectedDimensions : ['customer'], @@ -307,10 +321,28 @@ const DataGrid: React.FC = ({ data, hasCustomerFilter, adsData, s // Apply Pan-EU grouping when no customer filter is applied const processedData = applyPanEUGrouping(data as SalesRecord[], hasCustomerFilter); + // Apply our comprehensive filters (includes Column Filters now) + const filterState: any = { + customer: [], + year: [], + month: [], + line: [], + asin: [], + sku: [], + title: [], + week: [], + stock: [], + vendorStock: [], + woc: [], + bulkSearch: searchTerm, + columnFilters + }; + const filteredFlatData = filterData(processedData, filterState, stockMap, vendorStockMap, top50Mode); + // pivotSalesData now handles ads aggregation correctly because it receives CombinedKPIs - const { rows } = pivotSalesData(processedData, effectiveDimensions); + const { rows } = pivotSalesData(filteredFlatData, effectiveDimensions); return rows; - }, [data, effectiveDimensions, hasCustomerFilter]); + }, [data, effectiveDimensions, hasCustomerFilter, columnFilters, searchTerm, stockMap, vendorStockMap, top50Mode]); const { years } = useMemo(() => { // We still need unique years for columns @@ -452,13 +484,9 @@ const DataGrid: React.FC = ({ data, hasCustomerFilter, adsData, s // 1. Filter if (rowFilters.length > 0) { - const latestYear = years[0]; - const prevYear = years[1]; - result = result.filter(row => { return rowFilters.every(filter => { let rowValue = 0; - if (filter.metric.startsWith('total_sellOut_')) { const y = filter.metric.split('_')[2]; rowValue = row.totalsByYear[y]?.sellOut || 0; @@ -468,6 +496,8 @@ const DataGrid: React.FC = ({ data, hasCustomerFilter, adsData, s rowValue = row.totalsByYear[y]?.units || 0; } else if (filter.metric === 'growth_sellOut') { + const latestYear = years[0]; + const prevYear = years[1]; if (!prevYear) return true; const curr = row.totalsByYear[latestYear]?.sellOut || 0; const prev = row.totalsByYear[prevYear]?.sellOut || 0; @@ -475,6 +505,8 @@ const DataGrid: React.FC = ({ data, hasCustomerFilter, adsData, s rowValue = ((curr - prev) / prev) * 100; } else if (filter.metric === 'growth_units') { + const latestYear = years[0]; + const prevYear = years[1]; if (!prevYear) return true; const curr = row.totalsByYear[latestYear]?.units || 0; const prev = row.totalsByYear[prevYear]?.units || 0; @@ -1186,14 +1218,30 @@ const DataGrid: React.FC = ({ data, hasCustomerFilter, adsData, s {/* Dynamic Dimension Headers */} {effectiveDimensions.map(dim => { const label = DIMENSION_OPTIONS.find(d => d.value === dim)?.label || dim; + // Extract unique values for this dimension from ALL pivot rows (pre-filter) + const uniqueValues = Array.from(new Set(pivotRows.map(r => String((r as any)[dim] || '')))) + .filter(Boolean) + .sort(); + return ( requestSort(dim)} + className={`px-4 py-3 border-b border-border group bg-slate-950 ${dim === 'title' ? 'min-w-[450px]' : 'min-w-[150px]'}`} > -
- {label} {getSortIcon(dim)} +
+
requestSort(dim)} + > + {label} {getSortIcon(dim)} +
+
); diff --git a/components/ExcelFilter.tsx b/components/ExcelFilter.tsx new file mode 100644 index 0000000..766a7e4 --- /dev/null +++ b/components/ExcelFilter.tsx @@ -0,0 +1,246 @@ +import React, { useState, useRef, useEffect, useMemo } from 'react'; +import { ColumnFilterCondition } from '../types'; +import { CloseIcon, FunnelIcon } from './Icons'; + +interface ExcelFilterProps { + columnKey: string; + title: string; + uniqueValues: string[]; + currentFilter?: ColumnFilterCondition; + onFilterChange: (columnKey: string, condition: ColumnFilterCondition | undefined) => void; + icon?: React.ReactNode; +} + +const OPERATORS = [ + { label: 'Elija uno', value: '' }, + { label: 'Es igual a', value: 'equals' }, + { label: 'No es igual a', value: 'notEquals' }, + { label: 'Contiene', value: 'contains' }, + { label: 'No contiene', value: 'notContains' }, + { label: 'Comienza por', value: 'startsWith' }, + { label: 'No comienza por', value: 'notStartsWith' }, + { label: 'Termina con', value: 'endsWith' }, + { label: 'No termina con', value: 'notEndsWith' }, +] as const; + +export const ExcelFilter: React.FC = ({ + columnKey, + title, + uniqueValues, + currentFilter, + onFilterChange, + icon +}) => { + const [isOpen, setIsOpen] = useState(false); + const [operator, setOperator] = useState(currentFilter?.textFilter?.operator || ''); + const [textValue, setTextValue] = useState(currentFilter?.textFilter?.value || ''); + const [searchValue, setSearchValue] = useState(''); + const [tempSelectedValues, setTempSelectedValues] = useState(currentFilter?.selectedValues || []); + const containerRef = useRef(null); + + // Synchronization with currentFilter prop + useEffect(() => { + if (isOpen) { + setOperator(currentFilter?.textFilter?.operator || ''); + setTextValue(currentFilter?.textFilter?.value || ''); + setTempSelectedValues(currentFilter?.selectedValues || []); + } + }, [isOpen, currentFilter]); + + // 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 filteredValues = useMemo(() => { + if (!searchValue) return uniqueValues; + const lowerSearch = searchValue.toLowerCase(); + return uniqueValues.filter(v => String(v).toLowerCase().includes(lowerSearch)); + }, [uniqueValues, searchValue]); + + const handleToggleSelectAll = () => { + if (tempSelectedValues.length === uniqueValues.length) { + setTempSelectedValues([]); + } else { + setTempSelectedValues([...uniqueValues]); + } + }; + + const handleToggleValue = (val: string) => { + setTempSelectedValues(prev => + prev.includes(val) ? prev.filter(v => v !== val) : [...prev, val] + ); + }; + + const handleApply = () => { + const newCondition: ColumnFilterCondition = { + ...currentFilter, + selectedValues: tempSelectedValues.length > 0 ? tempSelectedValues : undefined, + textFilter: operator ? { operator: operator as any, value: textValue } : undefined + }; + + // Remove empty properties + if (!newCondition.selectedValues) delete newCondition.selectedValues; + if (!newCondition.textFilter) delete newCondition.textFilter; + + onFilterChange(columnKey, Object.keys(newCondition).length > 0 ? newCondition : undefined); + setIsOpen(false); + }; + + const handleClear = () => { + onFilterChange(columnKey, undefined); + setOperator(''); + setTextValue(''); + setTempSelectedValues([]); + setIsOpen(false); + }; + + const handleSort = (direction: 'asc' | 'desc') => { + onFilterChange(columnKey, { ...currentFilter, sort: direction }); + setIsOpen(false); + }; + + const hasActiveFilter = !!(currentFilter?.textFilter || currentFilter?.selectedValues); + + return ( +
+ + + {isOpen && ( +
+
+ {title} + +
+ +
+ {/* Sort Section */} +
+ Ordenar +
+ + +
+
+ + {/* Condition Filter Section */} +
+ Filtro +
+ + {operator && ( + setTextValue(e.target.value)} + placeholder="Valor..." + className="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-xs text-white focus:outline-none focus:border-indigo-500" + autoFocus + /> + )} +
+
+ + {/* List Selection Section */} +
+
+ Seleccionar valores +
+ + setSearchValue(e.target.value)} + className="w-full bg-slate-950 border border-slate-800 rounded-lg px-3 py-1.5 text-xs text-white focus:outline-none focus:border-indigo-500 mb-2" + /> + +
+
+
+ {tempSelectedValues.length === uniqueValues.length && } +
+ (Seleccionar todo) +
+ {filteredValues.map(val => { + const isSelected = tempSelectedValues.includes(val); + return ( +
handleToggleValue(val)} + className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-white/5 cursor-pointer text-xs text-slate-300" + > +
+ {isSelected && } +
+ {val} +
+ ); + })} +
+
+
+ +
+ + +
+
+ )} +
+ ); +}; + +const CheckMark = () => ( + + + +); diff --git a/components/ForecastView.tsx b/components/ForecastView.tsx index da0dc16..ac578ea 100644 --- a/components/ForecastView.tsx +++ b/components/ForecastView.tsx @@ -13,6 +13,8 @@ import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, LineChart, Line, Legend, ComposedChart, Area } from 'recharts'; +import { ExcelFilter } from './ExcelFilter'; +import { ColumnFilterCondition } from '../types'; interface ForecastViewProps { data: ProductForecastData[]; @@ -195,6 +197,18 @@ const ForecastView: React.FC = ({ const [displayCount, setDisplayCount] = useState(50); const [sortConfig, setSortConfig] = useState<{ key: string; direction: 'asc' | 'desc' }>({ key: 'actualUnits', direction: 'desc' }); + // State for Column Filters (SKU, ASIN, Title, Line) + const [columnFilters, setColumnFilters] = useState>({}); + + const handleColumnFilterChange = (columnKey: string, condition: ColumnFilterCondition | undefined) => { + setColumnFilters(prev => { + const next = { ...prev }; + if (condition) next[columnKey] = condition; + else delete next[columnKey]; + return next; + }); + }; + const activeMonths = useMemo(() => { if (filters.month && filters.month.length > 0) { return filters.month.map(m => m.split('-')[0]); @@ -262,6 +276,40 @@ const ForecastView: React.FC = ({ } } + // Column Filters (Excel-style) + if (Object.keys(columnFilters).length > 0) { + (Object.entries(columnFilters) as [string, ColumnFilterCondition][]).forEach(([key, condition]) => { + if (!condition) return; + + if (condition.selectedValues && condition.selectedValues.length > 0) { + result = result.filter(p => { + const val = String((p as any)[key] || ''); + return condition.selectedValues?.includes(val); + }); + } + + if (condition.textFilter) { + const { operator, value } = condition.textFilter; + const lowerValue = value.toLowerCase(); + + result = result.filter(p => { + const rowVal = String((p as any)[key] || '').toLowerCase(); + switch (operator) { + case 'equals': return rowVal === lowerValue; + case 'notEquals': return rowVal !== lowerValue; + case 'contains': return rowVal.includes(lowerValue); + case 'notContains': return !rowVal.includes(lowerValue); + case 'startsWith': return rowVal.startsWith(lowerValue); + case 'notStartsWith': return !rowVal.startsWith(lowerValue); + case 'endsWith': return rowVal.endsWith(lowerValue); + case 'notEndsWith': return !rowVal.endsWith(lowerValue); + default: return true; + } + }); + } + }); + } + // Apply WOC Filter if (wocFilter && wocFilter.length > 0 && vendorStockMap) { result = result.filter(p => { @@ -304,7 +352,7 @@ const ForecastView: React.FC = ({ }); return result; - }, [calculatedData, debouncedSearch, showOnlyTop50, top50Ranking, top50Mode, sortConfig, wocFilter, vendorStockMap]); + }, [calculatedData, debouncedSearch, showOnlyTop50, top50Ranking, top50Mode, sortConfig, wocFilter, vendorStockMap, columnFilters]); // [MOVED HERE] Global Summary - Now respects all filters including Search/WOC const globalSummary = useMemo(() => { @@ -492,8 +540,75 @@ const ForecastView: React.FC = ({ - {weeks.map(week => ( diff --git a/services/dataProcessor.ts b/services/dataProcessor.ts index 385b83e..fb61ee7 100644 --- a/services/dataProcessor.ts +++ b/services/dataProcessor.ts @@ -871,7 +871,46 @@ export const filterData = ( vendorStockMap?: Map, top50Mode: 'eu' | 'uk' = 'eu' ): SalesRecord[] => { - return data.filter(item => { + let result = data; // Changed from rawData to data + + // 1. Column Filters (Excel-style) + if (filters.columnFilters) { + Object.entries(filters.columnFilters).forEach(([key, condition]) => { + if (!condition) return; + + // Apply selected values filter + if (condition.selectedValues && condition.selectedValues.length > 0) { + result = result.filter(r => { + const val = String((r as any)[key] || ''); + return condition.selectedValues?.includes(val); + }); + } + + // Apply text condition filter + if (condition.textFilter) { + const { operator, value } = condition.textFilter; + const lowerValue = value.toLowerCase(); + + result = result.filter(r => { + const rowVal = String((r as any)[key] || '').toLowerCase(); + switch (operator) { + case 'equals': return rowVal === lowerValue; + case 'notEquals': return rowVal !== lowerValue; + case 'contains': return rowVal.includes(lowerValue); + case 'notContains': return !rowVal.includes(lowerValue); + case 'startsWith': return rowVal.startsWith(lowerValue); + case 'notStartsWith': return !rowVal.startsWith(lowerValue); + case 'endsWith': return rowVal.endsWith(lowerValue); + case 'notEndsWith': return !rowVal.endsWith(lowerValue); + default: return true; + } + }); + } + }); + } + + // 2. Standard Filters + return result.filter(item => { // Apply remaining filters to the 'result' // 1. Month Logic: Handle "Apr-23" matching "Apr" filter const recordMonth = item.month; // e.g. "Apr-23" const pureMonth = recordMonth.split('-')[0]; // "Apr" diff --git a/types.ts b/types.ts index 1272110..4769d0d 100644 --- a/types.ts +++ b/types.ts @@ -27,6 +27,16 @@ export interface FilterState { vendorStock: string[]; // Added Vendor Stock filter woc: string[]; // Added Weeks of Coverage filter bulkSearch: string; // Added Bulk Search support + columnFilters: Record; // Added Excel-style column filters +} + +export interface ColumnFilterCondition { + textFilter?: { + operator: 'equals' | 'notEquals' | 'contains' | 'notContains' | 'startsWith' | 'notStartsWith' | 'endsWith' | 'notEndsWith'; + value: string; + }; + selectedValues?: string[]; + sort?: 'asc' | 'desc'; } export interface GrowthMetric {
handleSort('sku')}> - Product Info + +
+
+ handleSort('sku')} + > + Product Info + +
+ d.sku))).sort()} + currentFilter={columnFilters['sku']} + onFilterChange={handleColumnFilterChange} + icon={SKU} + /> + d.asin))).sort()} + currentFilter={columnFilters['asin']} + onFilterChange={handleColumnFilterChange} + icon={ASIN} + /> + d.title))).sort()} + currentFilter={columnFilters['title']} + onFilterChange={handleColumnFilterChange} + icon={Title} + /> +
+
+
+ } + /> + } + options={[ + 'Out of Stock (0)', + 'In Stock (>0)', + 'In Stock (>20)', + 'Low Stock (<10)', + ]} + /> + } + options={[ + '< 4 Weeks', + '> 4 Weeks', + 'Out of Stock', + 'Infinite Cover' + ]} + /> +
+
handleSort('annualForecast')}> Annual Forecast diff --git a/components/WeeklyGrid.tsx b/components/WeeklyGrid.tsx index 30d71fa..1c99b36 100644 --- a/components/WeeklyGrid.tsx +++ b/components/WeeklyGrid.tsx @@ -1,7 +1,7 @@ import React, { useMemo, useState, useEffect, useCallback, useRef } from 'react'; import * as XLSX from 'xlsx'; -import { CombinedKPIs } from '../types'; -import { pivotWeeklySalesData, WeeklyPivotRow, PAN_EU_COUNTRIES, checkNumericConditions } from '../services/dataProcessor'; +import { CombinedKPIs, ColumnFilterCondition, SalesRecord } from '../types'; +import { pivotWeeklySalesData, WeeklyPivotRow, PAN_EU_COUNTRIES, checkNumericConditions, filterData } from '../services/dataProcessor'; import { StockBadge } from './StockBadge'; import { InColumnStockFilter } from './InColumnStockFilter'; import { NumericColumnFilter, NumericFilterConfig, passesNumericFilter } from './NumericColumnFilter'; @@ -9,6 +9,7 @@ import { Top50Badge } from './Top50Badge'; import { VendorStockBadge } from './VendorStockBadge'; import { BuyBoxWarningBadge } from './BuyBoxWarningBadge'; import { WarehouseIcon, AmazonSmileIcon, CoverageIcon } from './Icons'; +import { ExcelFilter } from './ExcelFilter'; interface WeeklyGridProps { data: CombinedKPIs[]; @@ -325,6 +326,19 @@ const WeeklyGrid: React.FC = ({ const [displayCount, setDisplayCount] = useState(50); const scrollContainerRef = useRef(null); + // State for Column Filters (SKU, ASIN, Title, Line) + const [columnFilters, setColumnFilters] = useState>({}); + + const handleColumnFilterChange = (columnKey: string, condition: ColumnFilterCondition | undefined) => { + setColumnFilters(prev => { + const next = { ...prev }; + if (condition) next[columnKey] = condition; + else delete next[columnKey]; + return next; + }); + setCurrentPage(1); + }; + // Numeric filters for Units, Spend, GV const [numericFilters, setNumericFilters] = useState([]); @@ -409,6 +423,40 @@ const WeeklyGrid: React.FC = ({ }); } + // Column Filters (Excel-style) - using logic adapted from filterData + if (Object.keys(columnFilters).length > 0) { + (Object.entries(columnFilters) as [string, ColumnFilterCondition][]).forEach(([key, condition]) => { + if (!condition) return; + + if (condition.selectedValues && condition.selectedValues.length > 0) { + result = result.filter(r => { + const val = String((r as any)[key] || ''); + return condition.selectedValues?.includes(val); + }); + } + + if (condition.textFilter) { + const { operator, value } = condition.textFilter; + const lowerValue = value.toLowerCase(); + + result = result.filter(r => { + const rowVal = String((r as any)[key] || '').toLowerCase(); + switch (operator) { + case 'equals': return rowVal === lowerValue; + case 'notEquals': return rowVal !== lowerValue; + case 'contains': return rowVal.includes(lowerValue); + case 'notContains': return !rowVal.includes(lowerValue); + case 'startsWith': return rowVal.startsWith(lowerValue); + case 'notStartsWith': return !rowVal.startsWith(lowerValue); + case 'endsWith': return rowVal.endsWith(lowerValue); + case 'notEndsWith': return !rowVal.endsWith(lowerValue); + default: return true; + } + }); + } + }); + } + // Search Filter if (debouncedSearch) { const searchTerms = debouncedSearch.toLowerCase().split(/[\s,]+/).filter(t => t.length > 0); @@ -492,7 +540,7 @@ const WeeklyGrid: React.FC = ({ } return result; - }, [rows, debouncedSearch, showOnlyTop50, top50Ranking, growthFilterMode, growthThreshold, sortConfig, weeks, top50Mode, wocFilter, velocityMap, vendorStockMap, numericFilters]); + }, [rows, debouncedSearch, showOnlyTop50, top50Ranking, growthFilterMode, growthThreshold, sortConfig, weeks, top50Mode, wocFilter, velocityMap, vendorStockMap, numericFilters, columnFilters]); // 2. Sort results const sortedRows = useMemo(() => { @@ -696,11 +744,56 @@ const WeeklyGrid: React.FC = ({
handleSort('rank', 'rank')} - className="p-3 text-[11px] font-black text-slate-400 uppercase tracking-widest sticky left-0 z-40 bg-slate-900 border-r border-white/10 min-w-[240px] cursor-pointer hover:bg-white/5 transition-colors group" + className="p-3 text-[11px] font-black text-slate-400 uppercase tracking-widest sticky left-0 z-40 bg-slate-900 border-r border-white/10 min-w-[240px]" > -
- Product Details +
+
+
handleSort('rank', 'rank')} + > + Product Details + {sortConfig?.metric === 'rank' && ( + + {sortConfig.direction === 'asc' ? '↑' : '↓'} + + )} +
+
+ r.sku))).sort()} + currentFilter={columnFilters['sku']} + onFilterChange={handleColumnFilterChange} + icon={SKU} + /> + r.asin))).sort()} + currentFilter={columnFilters['asin']} + onFilterChange={handleColumnFilterChange} + icon={ASIN} + /> + r.title))).sort()} + currentFilter={columnFilters['title']} + onFilterChange={handleColumnFilterChange} + icon={Title} + /> + r.line))).sort()} + currentFilter={columnFilters['line']} + onFilterChange={handleColumnFilterChange} + icon={Line} + /> +
+
= ({ ]} />
- {sortConfig?.metric === 'rank' && ( - - {sortConfig.direction === 'asc' ? '↑' : '↓'} - - )}