mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 19:15: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 { pivotWeeklySalesData, WeeklyPivotRow, PAN_EU_COUNTRIES, checkNumericConditions } from '../services/dataProcessor';
|
||||||
import { StockBadge } from './StockBadge';
|
import { StockBadge } from './StockBadge';
|
||||||
import { InColumnStockFilter } from './InColumnStockFilter';
|
import { InColumnStockFilter } from './InColumnStockFilter';
|
||||||
|
import { NumericColumnFilter, NumericFilterConfig, passesNumericFilter } from './NumericColumnFilter';
|
||||||
import { Top50Badge } from './Top50Badge';
|
import { Top50Badge } from './Top50Badge';
|
||||||
import { VendorStockBadge } from './VendorStockBadge';
|
import { VendorStockBadge } from './VendorStockBadge';
|
||||||
import { BuyBoxWarningBadge } from './BuyBoxWarningBadge';
|
import { BuyBoxWarningBadge } from './BuyBoxWarningBadge';
|
||||||
@@ -182,6 +183,26 @@ const WeeklyGrid: React.FC<WeeklyGridProps & { top50Mode: 'eu' | 'uk' }> = ({
|
|||||||
const [displayCount, setDisplayCount] = useState(50);
|
const [displayCount, setDisplayCount] = useState(50);
|
||||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
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
|
// Default sort: most recent week, descending, units
|
||||||
const [sortConfig, setSortConfig] = useState<SortConfig>(() => {
|
const [sortConfig, setSortConfig] = useState<SortConfig>(() => {
|
||||||
if (weeks.length > 0) {
|
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;
|
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
|
// 2. Sort results
|
||||||
const sortedRows = useMemo(() => {
|
const sortedRows = useMemo(() => {
|
||||||
@@ -571,33 +609,66 @@ const WeeklyGrid: React.FC<WeeklyGridProps & { top50Mode: 'eu' | 'uk' }> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
onClick={() => handleSort(week, 'units')}
|
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'}`}
|
||||||
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'}`}
|
|
||||||
>
|
>
|
||||||
<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' && (
|
{sortConfig?.key === week && sortConfig.metric === 'units' && (
|
||||||
<span className="text-xs font-bold leading-none">{sortConfig.direction === 'asc' ? '↑' : '↓'}</span>
|
<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>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
onClick={() => handleSort(week, 'spend')}
|
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'}`}
|
||||||
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'}`}
|
|
||||||
>
|
>
|
||||||
<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' && (
|
{sortConfig?.key === week && sortConfig.metric === 'spend' && (
|
||||||
<span className="text-xs font-bold leading-none">{sortConfig.direction === 'asc' ? '↑' : '↓'}</span>
|
<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>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
onClick={() => handleSort(week, 'gv')}
|
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'}`}
|
||||||
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'}`}
|
|
||||||
>
|
>
|
||||||
<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' && (
|
{sortConfig?.key === week && sortConfig.metric === 'gv' && (
|
||||||
<span className="text-xs font-bold leading-none">{sortConfig.direction === 'asc' ? '↑' : '↓'}</span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
|
|||||||
Reference in New Issue
Block a user