mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 12:25:22 +02:00
Add numeric column filters for Units, Spend and GV in Weekly Sales
This commit is contained in:
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user