mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 16:45:24 +02:00
249 lines
10 KiB
TypeScript
249 lines
10 KiB
TypeScript
import React, { useState, useRef, useEffect } from 'react';
|
|
|
|
export interface NumericFilterConfig {
|
|
week: string;
|
|
metric: 'units' | 'spend' | 'revenue' | 'gv';
|
|
operator: '=' | '>' | '>=' | '<' | '<=' | 'between';
|
|
value: number;
|
|
value2?: number;
|
|
}
|
|
|
|
interface NumericColumnFilterProps {
|
|
week: string;
|
|
metric: 'units' | 'spend' | 'revenue' | '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;
|
|
}
|
|
};
|