feat: implement advanced Excel-style Custom Filter for SKU and Name columns and fix search clear bug

This commit is contained in:
Christian Vidal Wolf
2026-04-24 12:27:30 +02:00
parent 6a258c3fb6
commit dbc47d95cb
+124 -38
View File
@@ -80,7 +80,8 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
const [lineMultiFilter, setLineMultiFilter] = useState<string[]>([]); const [lineMultiFilter, setLineMultiFilter] = useState<string[]>([]);
const [classificationFilter, setClassificationFilter] = useState<string[]>([]); const [classificationFilter, setClassificationFilter] = useState<string[]>([]);
const [productTypeFilter, setProductTypeFilter] = useState<string[]>([]); const [productTypeFilter, setProductTypeFilter] = useState<string[]>([]);
const [nameColFilter, setNameColFilter] = useState(''); const [nameColFilter, setNameColFilter] = useState<{ t1: string; t2: string; op: 'and' | 'or' }>({ t1: '', t2: '', op: 'and' });
const [articleNoColFilter, setArticleNoColFilter] = useState<{ t1: string; t2: string; op: 'and' | 'or' }>({ t1: '', t2: '', op: 'and' });
const [openFilter, setOpenFilter] = useState<string | null>(null); const [openFilter, setOpenFilter] = useState<string | null>(null);
const [unitsOuterFilter, setUnitsOuterFilter] = useState<string[]>([]); const [unitsOuterFilter, setUnitsOuterFilter] = useState<string[]>([]);
const [outerWFilter, setOuterWFilter] = useState<string[]>([]); const [outerWFilter, setOuterWFilter] = useState<string[]>([]);
@@ -335,15 +336,24 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
} }
// Column filters // Column filters
// Column-specific name filter - Multi-word AND support // Column-specific name filter - Excel-style Custom Filter
if (nameColFilter) { if (nameColFilter.t1 || nameColFilter.t2) {
const terms = nameColFilter.toLowerCase().split(/\s+/).filter(Boolean); result = result.filter(r => {
if (terms.length > 0) { const name = String(r.row[COLUMNS.ARTICLE_NAME] || '').toLowerCase();
result = result.filter(r => { const m1 = nameColFilter.t1 ? name.includes(nameColFilter.t1.toLowerCase()) : (nameColFilter.op === 'and');
const name = String(r.row[COLUMNS.ARTICLE_NAME] || '').toLowerCase(); const m2 = nameColFilter.t2 ? name.includes(nameColFilter.t2.toLowerCase()) : (nameColFilter.op === 'and');
return terms.every(term => name.includes(term)); return nameColFilter.op === 'and' ? (m1 && m2) : (m1 || m2);
}); });
} }
// Column-specific SKU filter - Excel-style Custom Filter
if (articleNoColFilter.t1 || articleNoColFilter.t2) {
result = result.filter(r => {
const sku = String(r.row[COLUMNS.ARTICLE_NO] || '').toLowerCase();
const m1 = articleNoColFilter.t1 ? sku.includes(articleNoColFilter.t1.toLowerCase()) : (articleNoColFilter.op === 'and');
const m2 = articleNoColFilter.t2 ? sku.includes(articleNoColFilter.t2.toLowerCase()) : (articleNoColFilter.op === 'and');
return articleNoColFilter.op === 'and' ? (m1 && m2) : (m1 || m2);
});
} }
if (lineMultiFilter.length > 0) { if (lineMultiFilter.length > 0) {
result = result.filter(r => lineMultiFilter.includes(String(r.row[COLUMNS.LINE] || ''))); result = result.filter(r => lineMultiFilter.includes(String(r.row[COLUMNS.LINE] || '')));
@@ -761,12 +771,17 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
/> />
{selectedSearchItems.size > 0 ? ( {selectedSearchItems.size > 0 ? (
<button <button
onClick={handleClearSearch} onClick={(e) => {
className="absolute right-3 top-1/2 -translate-y-1/2 text-blue-400 hover:text-white flex items-center gap-1" e.stopPropagation();
handleClearSearch();
}}
className="absolute right-2 top-1/2 -translate-y-1/2 bg-blue-600 hover:bg-blue-700 text-white flex items-center gap-1.5 px-2 py-1 rounded-md transition-all shadow-lg active:scale-95"
title="Clear selection"
> >
<span className="text-xs bg-blue-600 text-white rounded-full w-5 h-5 flex items-center justify-center"> <span className="text-[10px] font-bold">
{selectedSearchItems.size} {selectedSearchItems.size}
</span> </span>
<X className="w-3.5 h-3.5" />
</button> </button>
) : search ? ( ) : search ? (
<button <button
@@ -823,10 +838,14 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
</div> </div>
<div className="flex items-center justify-between px-4 py-2 bg-slate-900/50 border-t border-slate-700"> <div className="flex items-center justify-between px-4 py-2 bg-slate-900/50 border-t border-slate-700">
<button <button
onClick={handleClearSearch} onClick={(e) => {
className="text-xs text-slate-400 hover:text-white" e.stopPropagation();
handleClearSearch();
}}
className="flex items-center gap-1 text-xs text-red-400 hover:text-red-300 font-medium transition-colors"
> >
Clear ({selectedSearchItems.size}) <X className="w-3 h-3" />
Clear Selection ({selectedSearchItems.size})
</button> </button>
<button <button
onClick={handleApplySearch} onClick={handleApplySearch}
@@ -909,10 +928,26 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
<table ref={tableRef} className="w-full text-sm border-collapse table-fixed"> <table ref={tableRef} className="w-full text-sm border-collapse table-fixed">
<thead className="sticky top-0 z-10 bg-slate-900 border-b border-slate-700"> <thead className="sticky top-0 z-10 bg-slate-900 border-b border-slate-700">
<tr> <tr>
<th style={{ width: columnWidths.articleNo }} className="text-left px-3 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider whitespace-nowrap relative group cursor-pointer hover:bg-slate-800/50 transition-colors" onClick={() => handleSort('articleNo')}> <th style={{ width: columnWidths.articleNo }} className="text-left px-3 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider group cursor-pointer hover:bg-slate-800/50 transition-colors relative" onClick={() => handleSort('articleNo')}>
<div className="flex items-center justify-between gap-1"> <div className="flex items-center gap-1">
<span className="flex items-center gap-1">Art. No. <SortIcon current={sortConfig.key === 'articleNo' ? sortConfig.direction : null} /></span> <span className="truncate flex items-center gap-1">Article No <SortIcon current={sortConfig.key === 'articleNo' ? sortConfig.direction : null} /></span>
<button
onClick={(e) => { e.stopPropagation(); setOpenFilter(openFilter === 'sku' ? null : 'sku'); }}
className={cn(
'p-0.5 rounded hover:bg-slate-700 transition-colors shrink-0',
(articleNoColFilter.t1 || articleNoColFilter.t2) ? 'text-blue-400 bg-blue-400/10' : 'text-slate-500 opacity-0 group-hover:opacity-100'
)}
>
<Filter className="w-3 h-3" />
</button>
</div> </div>
{openFilter === 'sku' && (
<TextFilterPopover
value={articleNoColFilter}
onChange={setArticleNoColFilter}
onClose={() => setOpenFilter(null)}
/>
)}
<ResizeHandle onMouseDown={e => { e.stopPropagation(); handleResizeStart(e, 'articleNo', columnWidths.articleNo); }} /> <ResizeHandle onMouseDown={e => { e.stopPropagation(); handleResizeStart(e, 'articleNo', columnWidths.articleNo); }} />
</th> </th>
<th style={{ width: columnWidths.articleName }} className="text-left px-3 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider group cursor-pointer hover:bg-slate-800/50 transition-colors relative" onClick={() => handleSort('articleName')}> <th style={{ width: columnWidths.articleName }} className="text-left px-3 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider group cursor-pointer hover:bg-slate-800/50 transition-colors relative" onClick={() => handleSort('articleName')}>
@@ -922,7 +957,7 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
onClick={(e) => { e.stopPropagation(); setOpenFilter(openFilter === 'name' ? null : 'name'); }} onClick={(e) => { e.stopPropagation(); setOpenFilter(openFilter === 'name' ? null : 'name'); }}
className={cn( className={cn(
'p-0.5 rounded hover:bg-slate-700 transition-colors shrink-0', 'p-0.5 rounded hover:bg-slate-700 transition-colors shrink-0',
nameColFilter ? 'text-blue-400 bg-blue-400/10' : 'text-slate-500 opacity-0 group-hover:opacity-100' (nameColFilter.t1 || nameColFilter.t2) ? 'text-blue-400 bg-blue-400/10' : 'text-slate-500 opacity-0 group-hover:opacity-100'
)} )}
> >
<Filter className="w-3 h-3" /> <Filter className="w-3 h-3" />
@@ -1607,29 +1642,80 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
// ── Text filter popover ─────────────────────────────────────────────────────── // ── Text filter popover ───────────────────────────────────────────────────────
function TextFilterPopover({ value, onChange, onClose }: { function TextFilterPopover({ value, onChange, onClose }: {
value: string; value: { t1: string; t2: string; op: 'and' | 'or' };
onChange: (v: string) => void; onChange: (v: { t1: string; t2: string; op: 'and' | 'or' }) => void;
onClose: () => void; onClose: () => void;
}) { }) {
return ( return (
<div className="absolute top-full left-0 mt-1 w-56 bg-slate-800 border border-slate-700 rounded-lg shadow-2xl z-50 p-3 flex flex-col gap-3 animate-in fade-in zoom-in-95 duration-100"> <div className="absolute top-full left-0 mt-1 w-64 bg-slate-800 border border-slate-700 rounded-lg shadow-2xl z-50 p-4 flex flex-col gap-4 animate-in fade-in zoom-in-95 duration-100">
<div className="relative"> <div className="flex flex-col gap-3">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-slate-500" /> <div className="flex flex-col gap-1.5">
<input <label className="text-[10px] font-bold text-slate-500 uppercase tracking-wider">Show rows where name contains:</label>
type="text" <div className="relative">
placeholder="Search (multiple words supported)..." <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-slate-400" />
value={value} <input
onChange={e => onChange(e.target.value)} type="text"
autoFocus placeholder="First term..."
className="w-full bg-slate-900 border border-slate-700 rounded p-1.5 pl-8 text-xs text-white focus:outline-none focus:border-blue-500" value={value.t1}
/> onChange={e => onChange({ ...value, t1: e.target.value })}
<div className="mt-1 text-[9px] text-slate-500 italic px-1"> autoFocus
Use spaces to search multiple terms (AND logic) className="w-full bg-slate-900 border border-slate-700 rounded-md p-2 pl-8 text-xs text-white focus:outline-none focus:border-blue-500 transition-all"
/>
</div>
</div>
<div className="flex items-center gap-4 px-1">
<label className="flex items-center gap-2 cursor-pointer group">
<input
type="radio"
name="op"
checked={value.op === 'and'}
onChange={() => onChange({ ...value, op: 'and' })}
className="w-3 h-3 text-blue-600 bg-slate-900 border-slate-700 focus:ring-blue-500"
/>
<span className={cn("text-[10px] font-bold transition-colors", value.op === 'and' ? "text-blue-400" : "text-slate-500 group-hover:text-slate-300")}>AND</span>
</label>
<label className="flex items-center gap-2 cursor-pointer group">
<input
type="radio"
name="op"
checked={value.op === 'or'}
onChange={() => onChange({ ...value, op: 'or' })}
className="w-3 h-3 text-blue-600 bg-slate-900 border-slate-700 focus:ring-blue-500"
/>
<span className={cn("text-[10px] font-bold transition-colors", value.op === 'or' ? "text-blue-400" : "text-slate-500 group-hover:text-slate-300")}>OR</span>
</label>
</div>
<div className="flex flex-col gap-1.5">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-slate-400" />
<input
type="text"
placeholder="Second term..."
value={value.t2}
onChange={e => onChange({ ...value, t2: e.target.value })}
className="w-full bg-slate-900 border border-slate-700 rounded-md p-2 pl-8 text-xs text-white focus:outline-none focus:border-blue-500 transition-all"
/>
</div>
</div> </div>
</div> </div>
<div className="flex items-center justify-between pt-1 border-t border-slate-700">
<button onClick={() => { onChange(''); onClose(); }} className="text-[10px] font-medium text-slate-400 hover:text-white transition-colors">Clear</button> <div className="flex items-center justify-between pt-2 border-t border-slate-700/50">
<button onClick={onClose} className="px-3 py-1 bg-blue-600 hover:bg-blue-700 text-white text-[10px] font-bold rounded transition-colors">OK</button> <button
onClick={() => { onChange({ t1: '', t2: '', op: 'and' }); onClose(); }}
className="text-[10px] font-bold text-slate-500 hover:text-red-400 transition-colors"
>
Clear All
</button>
<div className="flex gap-2">
<button
onClick={onClose}
className="px-4 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-[10px] font-bold rounded shadow-lg shadow-blue-900/20 active:scale-95 transition-all"
>
Apply Filter
</button>
</div>
</div> </div>
</div> </div>
); );