Add numeric column filters for Units, Spend and GV in Weekly Sales

This commit is contained in:
Christian Vidal Wolf
2026-01-31 12:11:33 +01:00
parent 2a199d397e
commit 3e334c1fde
2 changed files with 329 additions and 10 deletions
+248
View File
@@ -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<NumericColumnFilterProps> = ({
week,
metric,
currentFilter,
onFilterChange,
accentColor = 'indigo'
}) => {
const [isOpen, setIsOpen] = useState(false);
const [operator, setOperator] = useState<NumericFilterConfig['operator']>(currentFilter?.operator || '>');
const [value, setValue] = useState<string>(currentFilter?.value?.toString() || '');
const [value2, setValue2] = useState<string>(currentFilter?.value2?.toString() || '');
const containerRef = useRef<HTMLDivElement>(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 (
<div className="relative inline-block" ref={containerRef}>
<button
onClick={(e) => {
e.stopPropagation();
setIsOpen(!isOpen);
}}
className={`flex items-center justify-center w-4 h-4 rounded text-[9px] font-black transition-all ${isActive
? colors.active
: `text-slate-600 ${colors.hover}`
}`}
title={isActive ? `${metric}: ${operatorSymbol} ${currentFilter?.value}${currentFilter?.value2 ? ` - ${currentFilter.value2}` : ''}` : `Filter ${metric}`}
>
{isActive ? operatorSymbol : (
<svg className="w-2.5 h-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
</svg>
)}
</button>
{isOpen && (
<div
className="absolute left-1/2 -translate-x-1/2 mt-2 w-52 bg-slate-900 border border-slate-700 rounded-xl shadow-2xl z-[9999] p-3 animate-in fade-in zoom-in duration-150"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex justify-between items-center mb-3 pb-2 border-b border-slate-800">
<span className="text-[10px] font-black text-slate-500 uppercase tracking-widest">
Filter {metric.toUpperCase()}
</span>
{isActive && (
<button
onClick={handleClear}
className={`text-[10px] font-bold ${colors.active.split(' ')[0]} hover:underline`}
>
Clear
</button>
)}
</div>
{/* Operator Select */}
<div className="mb-3">
<label className="block text-[9px] text-slate-500 uppercase tracking-wider mb-1.5 font-bold">
Condición
</label>
<select
value={operator}
onChange={(e) => setOperator(e.target.value as NumericFilterConfig['operator'])}
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 cursor-pointer"
>
{OPERATORS.map(op => (
<option key={op.value} value={op.value}>{op.label}</option>
))}
</select>
</div>
{/* Value Input(s) */}
<div className={`mb-3 ${operator === 'between' ? 'grid grid-cols-2 gap-2' : ''}`}>
<div>
<label className="block text-[9px] text-slate-500 uppercase tracking-wider mb-1.5 font-bold">
{operator === 'between' ? 'Mínimo' : 'Valor'}
</label>
<input
type="number"
value={value}
onChange={(e) => 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();
}}
/>
</div>
{operator === 'between' && (
<div>
<label className="block text-[9px] text-slate-500 uppercase tracking-wider mb-1.5 font-bold">
Máximo
</label>
<input
type="number"
value={value2}
onChange={(e) => 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();
}}
/>
</div>
)}
</div>
{/* Apply Button */}
<button
onClick={handleApply}
className={`w-full py-2 rounded-lg text-xs font-black uppercase tracking-widest text-white transition-all ${colors.button} hover:opacity-90 active:scale-[0.98]`}
>
Aplicar Filtro
</button>
</div>
)}
</div>
);
};
// 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;
}
};
+81 -10
View File
@@ -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<WeeklyGridProps & { top50Mode: 'eu' | 'uk' }> = ({
const [displayCount, setDisplayCount] = useState(50);
const scrollContainerRef = useRef<HTMLDivElement>(null);
// Numeric filters for Units, Spend, GV
const [numericFilters, setNumericFilters] = useState<NumericFilterConfig[]>([]);
// 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<SortConfig>(() => {
if (weeks.length > 0) {
@@ -311,8 +332,25 @@ const WeeklyGrid: React.FC<WeeklyGridProps & { top50Mode: 'eu' | 'uk' }> = ({
});
}
// 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<WeeklyGridProps & { top50Mode: 'eu' | 'uk' }> = ({
</div>
<div
onClick={() => 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'}`}
>
<span className="text-[9px]">Units</span>
<span
onClick={() => handleSort(week, 'units')}
className="text-[9px] cursor-pointer hover:text-indigo-300"
>
Units
</span>
{sortConfig?.key === week && sortConfig.metric === 'units' && (
<span className="text-xs font-bold leading-none">{sortConfig.direction === 'asc' ? '↑' : '↓'}</span>
)}
<NumericColumnFilter
week={week}
metric="units"
currentFilter={getNumericFilter(week, 'units')}
onFilterChange={(f) => setNumericFilter(f, week, 'units')}
accentColor="indigo"
/>
</div>
<div
onClick={() => 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'}`}
>
<span className="text-[9px]">Spend</span>
<span
onClick={() => handleSort(week, 'spend')}
className="text-[9px] cursor-pointer hover:text-amber-300"
>
Spend
</span>
{sortConfig?.key === week && sortConfig.metric === 'spend' && (
<span className="text-xs font-bold leading-none">{sortConfig.direction === 'asc' ? '↑' : '↓'}</span>
)}
<NumericColumnFilter
week={week}
metric="spend"
currentFilter={getNumericFilter(week, 'spend')}
onFilterChange={(f) => setNumericFilter(f, week, 'spend')}
accentColor="amber"
/>
</div>
<div
onClick={() => 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'}`}
>
<span className="text-[9px]">GV</span>
<span
onClick={() => handleSort(week, 'gv')}
className="text-[9px] cursor-pointer hover:text-teal-300"
>
GV
</span>
{sortConfig?.key === week && sortConfig.metric === 'gv' && (
<span className="text-xs font-bold leading-none">{sortConfig.direction === 'asc' ? '↑' : '↓'}</span>
)}
<NumericColumnFilter
week={week}
metric="gv"
currentFilter={getNumericFilter(week, 'gv')}
onFilterChange={(f) => setNumericFilter(f, week, 'gv')}
accentColor="teal"
/>
</div>
</div>
</th>