diff --git a/components/NumericColumnFilter.tsx b/components/NumericColumnFilter.tsx new file mode 100644 index 0000000..0b07f28 --- /dev/null +++ b/components/NumericColumnFilter.tsx @@ -0,0 +1,248 @@ +import React, { useState, useRef, useEffect } from 'react'; + +export interface NumericFilterConfig { + week: string; + metric: 'units' | 'spend' | 'gv'; + operator: '=' | '>' | '>=' | '<' | '<=' | 'between'; + value: number; + value2?: number; +} + +interface NumericColumnFilterProps { + week: string; + metric: 'units' | 'spend' | 'gv'; + currentFilter: NumericFilterConfig | null; + onFilterChange: (filter: NumericFilterConfig | null) => void; + accentColor?: 'indigo' | 'amber' | 'teal'; +} + +const OPERATORS = [ + { value: '=', label: '= Igual a', symbol: '=' }, + { value: '>', label: '> Mayor que', symbol: '>' }, + { value: '>=', label: '≥ Mayor o igual', symbol: '≥' }, + { value: '<', label: '< Menor que', symbol: '<' }, + { value: '<=', label: '≤ Menor o igual', symbol: '≤' }, + { value: 'between', label: '↔ Entre', symbol: '↔' }, +] as const; + +export const NumericColumnFilter: React.FC = ({ + week, + metric, + currentFilter, + onFilterChange, + accentColor = 'indigo' +}) => { + const [isOpen, setIsOpen] = useState(false); + const [operator, setOperator] = useState(currentFilter?.operator || '>'); + const [value, setValue] = useState(currentFilter?.value?.toString() || ''); + const [value2, setValue2] = useState(currentFilter?.value2?.toString() || ''); + const containerRef = useRef(null); + + const colorClasses = { + indigo: { + active: 'text-indigo-400 bg-indigo-500/10', + button: 'bg-indigo-500 border-indigo-400', + hover: 'hover:bg-indigo-500/10', + }, + amber: { + active: 'text-amber-400 bg-amber-500/10', + button: 'bg-amber-500 border-amber-400', + hover: 'hover:bg-amber-500/10', + }, + teal: { + active: 'text-teal-400 bg-teal-500/10', + button: 'bg-teal-500 border-teal-400', + hover: 'hover:bg-teal-500/10', + } + }; + + const colors = colorClasses[accentColor]; + + // Sync local state with prop + useEffect(() => { + if (currentFilter) { + setOperator(currentFilter.operator); + setValue(currentFilter.value.toString()); + setValue2(currentFilter.value2?.toString() || ''); + } else { + setOperator('>'); + setValue(''); + setValue2(''); + } + }, [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 handleApply = () => { + const numValue = parseFloat(value); + if (isNaN(numValue)) { + onFilterChange(null); + setIsOpen(false); + return; + } + + const filter: NumericFilterConfig = { + week, + metric, + operator, + value: numValue, + }; + + if (operator === 'between') { + const numValue2 = parseFloat(value2); + if (!isNaN(numValue2)) { + filter.value2 = numValue2; + } + } + + onFilterChange(filter); + setIsOpen(false); + }; + + const handleClear = () => { + onFilterChange(null); + setValue(''); + setValue2(''); + setOperator('>'); + setIsOpen(false); + }; + + const isActive = currentFilter !== null; + const operatorSymbol = OPERATORS.find(o => o.value === (currentFilter?.operator || operator))?.symbol || '▼'; + + return ( +
+ + + {isOpen && ( +
e.stopPropagation()} + > + {/* Header */} +
+ + Filter {metric.toUpperCase()} + + {isActive && ( + + )} +
+ + {/* Operator Select */} +
+ + +
+ + {/* Value Input(s) */} +
+
+ + setValue(e.target.value)} + placeholder="0" + className="w-full bg-slate-950 border border-white/10 rounded-lg px-2.5 py-2 text-xs text-white focus:outline-none focus:ring-2 focus:ring-indigo-500" + onKeyDown={(e) => { + if (e.key === 'Enter') handleApply(); + }} + /> +
+ {operator === 'between' && ( +
+ + setValue2(e.target.value)} + placeholder="100" + className="w-full bg-slate-950 border border-white/10 rounded-lg px-2.5 py-2 text-xs text-white focus:outline-none focus:ring-2 focus:ring-indigo-500" + onKeyDown={(e) => { + if (e.key === 'Enter') handleApply(); + }} + /> +
+ )} +
+ + {/* Apply Button */} + +
+ )} +
+ ); +}; + +// Helper function to check if a value passes a filter +export const passesNumericFilter = ( + value: number, + filter: NumericFilterConfig +): boolean => { + switch (filter.operator) { + case '=': + return value === filter.value; + case '>': + return value > filter.value; + case '>=': + return value >= filter.value; + case '<': + return value < filter.value; + case '<=': + return value <= filter.value; + case 'between': + return value >= filter.value && value <= (filter.value2 ?? filter.value); + default: + return true; + } +}; diff --git a/components/WeeklyGrid.tsx b/components/WeeklyGrid.tsx index d0429a5..7b3cf38 100644 --- a/components/WeeklyGrid.tsx +++ b/components/WeeklyGrid.tsx @@ -4,6 +4,7 @@ import { CombinedKPIs } from '../types'; import { pivotWeeklySalesData, WeeklyPivotRow, PAN_EU_COUNTRIES, checkNumericConditions } from '../services/dataProcessor'; import { StockBadge } from './StockBadge'; import { InColumnStockFilter } from './InColumnStockFilter'; +import { NumericColumnFilter, NumericFilterConfig, passesNumericFilter } from './NumericColumnFilter'; import { Top50Badge } from './Top50Badge'; import { VendorStockBadge } from './VendorStockBadge'; import { BuyBoxWarningBadge } from './BuyBoxWarningBadge'; @@ -182,6 +183,26 @@ const WeeklyGrid: React.FC = ({ const [displayCount, setDisplayCount] = useState(50); const scrollContainerRef = useRef(null); + // Numeric filters for Units, Spend, GV + const [numericFilters, setNumericFilters] = useState([]); + + // Helper to get/set filter for a specific week+metric + const getNumericFilter = useCallback((week: string, metric: 'units' | 'spend' | 'gv') => { + return numericFilters.find(f => f.week === week && f.metric === metric) || null; + }, [numericFilters]); + + const setNumericFilter = useCallback((filter: NumericFilterConfig | null, week: string, metric: 'units' | 'spend' | 'gv') => { + setNumericFilters(prev => { + // Remove existing filter for this week+metric + const filtered = prev.filter(f => !(f.week === week && f.metric === metric)); + // Add new filter if provided + if (filter) { + return [...filtered, filter]; + } + return filtered; + }); + }, []); + // Default sort: most recent week, descending, units const [sortConfig, setSortConfig] = useState(() => { if (weeks.length > 0) { @@ -311,8 +332,25 @@ const WeeklyGrid: React.FC = ({ }); } + // Numeric Filters (Units, Spend, GV) + if (numericFilters.length > 0) { + result = result.filter(row => { + return numericFilters.every(filter => { + let value = 0; + if (filter.metric === 'units') { + value = row.unitsByWeek[filter.week] || 0; + } else if (filter.metric === 'spend') { + value = row.spendByWeek[filter.week] || 0; + } else if (filter.metric === 'gv') { + value = row.gvByWeek?.[filter.week] || 0; + } + return passesNumericFilter(value, filter); + }); + }); + } + return result; - }, [rows, debouncedSearch, showOnlyTop50, top50Ranking, growthFilterMode, growthThreshold, sortConfig, weeks, top50Mode, wocFilter, velocityMap, vendorStockMap]); + }, [rows, debouncedSearch, showOnlyTop50, top50Ranking, growthFilterMode, growthThreshold, sortConfig, weeks, top50Mode, wocFilter, velocityMap, vendorStockMap, numericFilters]); // 2. Sort results const sortedRows = useMemo(() => { @@ -571,33 +609,66 @@ const WeeklyGrid: React.FC = ({
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'}`} + className={`flex-1 p-1.5 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'}`} > - Units + handleSort(week, 'units')} + className="text-[9px] cursor-pointer hover:text-indigo-300" + > + Units + {sortConfig?.key === week && sortConfig.metric === 'units' && ( {sortConfig.direction === 'asc' ? '↑' : '↓'} )} + setNumericFilter(f, week, 'units')} + accentColor="indigo" + />
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'}`} + className={`flex-1 p-1.5 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'}`} > - Spend + handleSort(week, 'spend')} + className="text-[9px] cursor-pointer hover:text-amber-300" + > + Spend + {sortConfig?.key === week && sortConfig.metric === 'spend' && ( {sortConfig.direction === 'asc' ? '↑' : '↓'} )} + setNumericFilter(f, week, 'spend')} + accentColor="amber" + />
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'}`} + className={`flex-1 p-1.5 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'}`} > - GV + handleSort(week, 'gv')} + className="text-[9px] cursor-pointer hover:text-teal-300" + > + GV + {sortConfig?.key === week && sortConfig.metric === 'gv' && ( {sortConfig.direction === 'asc' ? '↑' : '↓'} )} + setNumericFilter(f, week, 'gv')} + accentColor="teal" + />