mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 12:55:23 +02:00
252 lines
13 KiB
TypeScript
252 lines
13 KiB
TypeScript
import React, { useState, useRef, useEffect, useMemo } from 'react';
|
|
import { ColumnFilterCondition } from '../types';
|
|
import { CloseIcon, FunnelIcon } from './Icons';
|
|
|
|
interface ExcelFilterProps {
|
|
columnKey: string;
|
|
title: string;
|
|
uniqueValues: string[];
|
|
currentFilter?: ColumnFilterCondition;
|
|
onFilterChange: (columnKey: string, condition: ColumnFilterCondition | undefined) => void;
|
|
icon?: React.ReactNode;
|
|
}
|
|
|
|
const OPERATORS = [
|
|
{ label: 'Elija uno', value: '' },
|
|
{ label: 'Es igual a', value: 'equals' },
|
|
{ label: 'No es igual a', value: 'notEquals' },
|
|
{ label: 'Contiene', value: 'contains' },
|
|
{ label: 'No contiene', value: 'notContains' },
|
|
{ label: 'Comienza por', value: 'startsWith' },
|
|
{ label: 'No comienza por', value: 'notStartsWith' },
|
|
{ label: 'Termina con', value: 'endsWith' },
|
|
{ label: 'No termina con', value: 'notEndsWith' },
|
|
{ label: 'Mayor que', value: 'gt' },
|
|
{ label: 'Menor que', value: 'lt' },
|
|
{ label: 'Mayor o igual que', value: 'gte' },
|
|
{ label: 'Menor o igual que', value: 'lte' },
|
|
] as const;
|
|
|
|
export const ExcelFilter: React.FC<ExcelFilterProps> = ({
|
|
columnKey,
|
|
title,
|
|
uniqueValues,
|
|
currentFilter,
|
|
onFilterChange,
|
|
icon,
|
|
buttonClassName
|
|
}) => {
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [operator, setOperator] = useState<typeof OPERATORS[number]['value']>(currentFilter?.textFilter?.operator || '');
|
|
const [textValue, setTextValue] = useState(currentFilter?.textFilter?.value || '');
|
|
const [searchValue, setSearchValue] = useState('');
|
|
const [tempSelectedValues, setTempSelectedValues] = useState<string[]>(currentFilter?.selectedValues || []);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Synchronization with currentFilter prop
|
|
useEffect(() => {
|
|
if (isOpen) {
|
|
setOperator(currentFilter?.textFilter?.operator || '');
|
|
setTextValue(currentFilter?.textFilter?.value || '');
|
|
setTempSelectedValues(currentFilter?.selectedValues || []);
|
|
}
|
|
}, [isOpen, 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 filteredValues = useMemo(() => {
|
|
if (!searchValue) return uniqueValues;
|
|
const lowerSearch = searchValue.toLowerCase();
|
|
return uniqueValues.filter(v => String(v).toLowerCase().includes(lowerSearch));
|
|
}, [uniqueValues, searchValue]);
|
|
|
|
const handleToggleSelectAll = () => {
|
|
if (tempSelectedValues.length === uniqueValues.length) {
|
|
setTempSelectedValues([]);
|
|
} else {
|
|
setTempSelectedValues([...uniqueValues]);
|
|
}
|
|
};
|
|
|
|
const handleToggleValue = (val: string) => {
|
|
setTempSelectedValues(prev =>
|
|
prev.includes(val) ? prev.filter(v => v !== val) : [...prev, val]
|
|
);
|
|
};
|
|
|
|
const handleApply = () => {
|
|
const newCondition: ColumnFilterCondition = {
|
|
...currentFilter,
|
|
selectedValues: tempSelectedValues.length > 0 ? tempSelectedValues : undefined,
|
|
textFilter: operator ? { operator: operator as any, value: textValue } : undefined
|
|
};
|
|
|
|
// Remove empty properties
|
|
if (!newCondition.selectedValues) delete newCondition.selectedValues;
|
|
if (!newCondition.textFilter) delete newCondition.textFilter;
|
|
|
|
onFilterChange(columnKey, Object.keys(newCondition).length > 0 ? newCondition : undefined);
|
|
setIsOpen(false);
|
|
};
|
|
|
|
const handleClear = () => {
|
|
onFilterChange(columnKey, undefined);
|
|
setOperator('');
|
|
setTextValue('');
|
|
setTempSelectedValues([]);
|
|
setIsOpen(false);
|
|
};
|
|
|
|
const handleSort = (direction: 'asc' | 'desc') => {
|
|
onFilterChange(columnKey, { ...currentFilter, sort: direction });
|
|
setIsOpen(false);
|
|
};
|
|
|
|
const hasActiveFilter = !!(currentFilter?.textFilter || currentFilter?.selectedValues);
|
|
|
|
return (
|
|
<div className="relative inline-block" ref={containerRef}>
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setIsOpen(!isOpen);
|
|
}}
|
|
className={buttonClassName || `flex items-center justify-center p-1 rounded hover:bg-white/10 transition-all ${hasActiveFilter ? 'text-indigo-400 bg-indigo-500/10' : 'text-slate-500'}`}
|
|
title={`Filter by ${title}`}
|
|
>
|
|
{icon || <FunnelIcon className={buttonClassName?.includes('w-') ? '' : "w-3 h-3"} />}
|
|
</button>
|
|
|
|
{isOpen && (
|
|
<div className="absolute left-0 mt-1 w-64 bg-slate-900 border border-slate-700 rounded-xl shadow-2xl z-[999] overflow-hidden animate-in fade-in zoom-in duration-150">
|
|
<div className="bg-slate-950 px-3 py-2 border-b border-slate-800 flex justify-between items-center">
|
|
<span className="text-[10px] font-black text-slate-300 uppercase tracking-widest">{title}</span>
|
|
<button onClick={() => setIsOpen(false)} className="text-slate-500 hover:text-white">
|
|
<CloseIcon />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="p-3 space-y-3 max-h-[80vh] overflow-y-auto custom-scrollbar">
|
|
{/* Sort Section */}
|
|
<div className="space-y-1.5">
|
|
<span className="text-[9px] font-black text-slate-500 uppercase tracking-widest">Ordenar</span>
|
|
<div className="flex gap-1.5">
|
|
<button
|
|
onClick={() => handleSort('asc')}
|
|
className={`flex-1 flex items-center justify-center gap-1.5 px-2 py-1.5 rounded-lg border text-[10px] font-bold transition-all ${currentFilter?.sort === 'asc' ? 'bg-indigo-600 border-indigo-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'}`}
|
|
>
|
|
<span className="text-sm">A↓Z</span> Asc
|
|
</button>
|
|
<button
|
|
onClick={() => handleSort('desc')}
|
|
className={`flex-1 flex items-center justify-center gap-1.5 px-2 py-1.5 rounded-lg border text-[10px] font-bold transition-all ${currentFilter?.sort === 'desc' ? 'bg-indigo-600 border-indigo-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'}`}
|
|
>
|
|
<span className="text-sm">Z↓A</span> Desc
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Condition Filter Section */}
|
|
<div className="space-y-1.5">
|
|
<span className="text-[9px] font-black text-slate-500 uppercase tracking-widest">Filtro</span>
|
|
<div className="space-y-1.5">
|
|
<select
|
|
value={operator}
|
|
onChange={(e) => setOperator(e.target.value as any)}
|
|
className="w-full bg-slate-800 border border-slate-700 rounded-lg px-2 py-1.5 text-[11px] text-white focus:outline-none focus:border-indigo-500"
|
|
>
|
|
{OPERATORS.map(op => (
|
|
<option key={op.value} value={op.value}>{op.label}</option>
|
|
))}
|
|
</select>
|
|
{operator && (
|
|
<input
|
|
type="text"
|
|
value={textValue}
|
|
onChange={(e) => setTextValue(e.target.value)}
|
|
placeholder="Valor..."
|
|
className="w-full bg-slate-800 border border-slate-700 rounded-lg px-2 py-1.5 text-[11px] text-white focus:outline-none focus:border-indigo-500"
|
|
autoFocus
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* List Selection Section */}
|
|
<div className="space-y-1.5">
|
|
<div className="flex justify-between items-center mb-0.5">
|
|
<span className="text-[9px] font-black text-slate-500 uppercase tracking-widest">Seleccionar valores</span>
|
|
</div>
|
|
|
|
<input
|
|
type="text"
|
|
placeholder="Buscar en la lista..."
|
|
value={searchValue}
|
|
onChange={(e) => setSearchValue(e.target.value)}
|
|
className="w-full bg-slate-950 border border-slate-800 rounded-lg px-2 py-1 text-[11px] text-white focus:outline-none focus:border-indigo-500 mb-1.5"
|
|
/>
|
|
|
|
<div className="bg-slate-950 border border-slate-800 rounded-lg p-0.5 max-h-40 overflow-y-auto custom-scrollbar">
|
|
<div
|
|
onClick={handleToggleSelectAll}
|
|
className="flex items-center gap-1.5 px-1.5 py-1 rounded hover:bg-white/5 cursor-pointer text-[11px] font-bold text-indigo-400"
|
|
>
|
|
<div className={`w-3 h-3 rounded border flex items-center justify-center ${tempSelectedValues.length === uniqueValues.length ? 'bg-indigo-600 border-indigo-500' : 'border-slate-600'}`}>
|
|
{tempSelectedValues.length === uniqueValues.length && <CheckMark />}
|
|
</div>
|
|
(Seleccionar todo)
|
|
</div>
|
|
{filteredValues.map(val => {
|
|
const isSelected = tempSelectedValues.includes(val);
|
|
return (
|
|
<div
|
|
key={val}
|
|
onClick={() => handleToggleValue(val)}
|
|
className="flex items-center gap-1.5 px-1.5 py-1 rounded hover:bg-white/5 cursor-pointer text-[11px] text-slate-300"
|
|
>
|
|
<div className={`w-3 h-3 rounded border flex items-center justify-center ${isSelected ? 'bg-indigo-600 border-indigo-500' : 'border-slate-600'}`}>
|
|
{isSelected && <CheckMark />}
|
|
</div>
|
|
<span className="truncate">{val}</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-slate-950 p-3 border-t border-slate-800 flex gap-1.5">
|
|
<button
|
|
onClick={handleApply}
|
|
className="flex-1 bg-indigo-600 hover:bg-indigo-500 text-white py-1.5 rounded-lg text-[10px] font-black uppercase tracking-widest transition-all"
|
|
>
|
|
Aplicar
|
|
</button>
|
|
<button
|
|
onClick={handleClear}
|
|
className="flex-1 bg-slate-800 hover:bg-slate-700 text-slate-300 py-1.5 rounded-lg text-[10px] font-black uppercase tracking-widest transition-all"
|
|
>
|
|
Borrar
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const CheckMark = () => (
|
|
<svg className="w-2.5 h-2.5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={4}>
|
|
<path d="M5 13l4 4L19 7" />
|
|
</svg>
|
|
);
|