feat: standardize ColumnFilterPopover and add SELECT ALL to all views/tabs

This commit is contained in:
Christian Vidal Wolf
2026-04-08 16:56:43 +02:00
parent 786c66fd87
commit 89aa222470
6 changed files with 383 additions and 303 deletions
+2 -97
View File
@@ -1,7 +1,8 @@
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo } from 'react';
import { ExcelRow, COLUMNS } from '../types'; import { ExcelRow, COLUMNS } from '../types';
import { Search, Filter, Edit2, ChevronDown, ChevronUp, Info, Package, X as XIcon, Check } from 'lucide-react'; import { Search, Filter, Edit2, ChevronDown, ChevronUp, Info, Package, X as XIcon } from 'lucide-react';
import { cn } from '../lib/utils'; import { cn } from '../lib/utils';
import { ColumnFilterPopover } from './ColumnFilterPopover';
interface ArticleDetailsProps { interface ArticleDetailsProps {
data: ExcelRow[]; data: ExcelRow[];
@@ -319,99 +320,3 @@ export function ArticleDetails({ data, onEdit }: ArticleDetailsProps) {
</div> </div>
); );
} }
interface ColumnFilterPopoverProps {
uniqueValues: string[];
selectedValues: string[];
onToggle: (val: string) => void;
onSelectAll: (vals: string[]) => void;
onClear: () => void;
onClose: () => void;
}
function ColumnFilterPopover({ uniqueValues, selectedValues, onToggle, onSelectAll, onClear, onClose }: ColumnFilterPopoverProps) {
const [search, setSearch] = useState('');
const filteredValues = useMemo(() => {
return uniqueValues.filter(v => v.toLowerCase().includes(search.toLowerCase()));
}, [uniqueValues, search]);
return (
<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-3 flex flex-col gap-3 animate-in fade-in zoom-in-95 duration-100">
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-slate-500" />
<input
type="text"
placeholder="Filter values..."
value={search}
onChange={e => setSearch(e.target.value)}
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-indigo-500"
autoFocus
/>
</div>
<div className="flex items-center justify-between px-1">
<label className="flex items-center gap-2 py-1 px-1.5 hover:bg-slate-700/50 rounded cursor-pointer group">
<div className={cn(
"w-4 h-4 rounded border flex items-center justify-center transition-colors",
selectedValues.length === uniqueValues.length ? "bg-indigo-600 border-indigo-600" : "border-slate-600 bg-slate-900 group-hover:border-slate-500"
)}>
{selectedValues.length === uniqueValues.length && <Check className="w-3 h-3 text-white" />}
</div>
<input
type="checkbox"
className="hidden"
checked={selectedValues.length === uniqueValues.length}
onChange={() => {
if (selectedValues.length === uniqueValues.length) {
onSelectAll([]);
} else {
onSelectAll(uniqueValues);
}
}}
/>
<span className="text-[11px] font-semibold text-slate-300">Select All</span>
</label>
<span className="text-[10px] text-slate-500">{selectedValues.length} selected</span>
</div>
<div className="max-h-48 overflow-y-auto space-y-1 pr-1 custom-scrollbar">
{filteredValues.map(val => (
<label key={val} className="flex items-center gap-2 p-1.5 hover:bg-slate-700/50 rounded cursor-pointer group">
<div className={cn(
"w-4 h-4 rounded border flex items-center justify-center transition-colors",
selectedValues.includes(val) ? "bg-indigo-600 border-indigo-600" : "border-slate-600 bg-slate-900 group-hover:border-slate-500"
)}>
{selectedValues.includes(val) && <Check className="w-3 h-3 text-white" />}
</div>
<input
type="checkbox"
className="hidden"
checked={selectedValues.includes(val)}
onChange={() => onToggle(val)}
/>
<span className="text-xs text-slate-300 truncate" title={val}>{val || '(Empty)'}</span>
</label>
))}
{filteredValues.length === 0 && (
<div className="text-[10px] text-slate-500 text-center py-2">No values found</div>
)}
</div>
<div className="flex items-center justify-between pt-2 border-t border-slate-700 mt-1">
<button
onClick={onClear}
className="text-[10px] font-medium text-slate-400 hover:text-white transition-colors"
>
Clear Current
</button>
<button
onClick={onClose}
className="px-3 py-1 bg-indigo-600 hover:bg-indigo-700 text-white text-[10px] font-bold rounded transition-colors shadow-sm"
>
OK
</button>
</div>
</div>
);
}
+119
View File
@@ -0,0 +1,119 @@
import React, { useState, useMemo } from 'react';
import { Search, Check, X } from 'lucide-react';
import { cn } from '../lib/utils';
interface ColumnFilterPopoverProps {
uniqueValues: string[];
selectedValues: string[];
onToggle: (val: string) => void;
onSelectAll: (vals: string[]) => void;
onClear: () => void;
onClose: () => void;
title?: string;
className?: string;
}
export function ColumnFilterPopover({
uniqueValues,
selectedValues,
onToggle,
onSelectAll,
onClear,
onClose,
title,
className
}: ColumnFilterPopoverProps) {
const [search, setSearch] = useState('');
const filteredValues = useMemo(() => {
return uniqueValues.filter(v =>
String(v || '').toLowerCase().includes(search.toLowerCase())
);
}, [uniqueValues, search]);
const isAllSelected = selectedValues.length === uniqueValues.length && uniqueValues.length > 0;
return (
<div className={cn(
"absolute top-full left-0 mt-1 w-64 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",
className
)}>
{title && <div className="text-[10px] font-bold text-slate-500 uppercase tracking-wider px-1">{title}</div>}
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-slate-500" />
<input
type="text"
placeholder="Filter values..."
value={search}
onChange={e => setSearch(e.target.value)}
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"
autoFocus
/>
</div>
<div className="flex items-center justify-between px-1 border-b border-slate-700/50 pb-2">
<label className="flex items-center gap-2 py-1 px-1.5 hover:bg-slate-700/50 rounded cursor-pointer group">
<div className={cn(
"w-4 h-4 rounded border flex items-center justify-center shrink-0 transition-colors",
isAllSelected ? "bg-blue-600 border-blue-600" : "border-slate-600 bg-slate-900 group-hover:border-slate-500"
)}>
{isAllSelected && <Check className="w-3 h-3 text-white" />}
</div>
<input
type="checkbox"
className="hidden"
checked={isAllSelected}
onChange={() => {
if (isAllSelected) {
onSelectAll([]);
} else {
onSelectAll(uniqueValues);
}
}}
/>
<span className="text-[11px] font-bold text-slate-200">SELECT ALL</span>
</label>
<span className="text-[10px] text-slate-500 font-medium">{selectedValues.length} selected</span>
</div>
<div className="max-h-48 overflow-y-auto space-y-0.5 pr-1 custom-scrollbar">
{filteredValues.map(val => (
<label key={val} className="flex items-center gap-2 p-1.5 hover:bg-slate-700/50 rounded cursor-pointer group">
<div className={cn(
"w-4 h-4 rounded border flex items-center justify-center shrink-0 transition-colors",
selectedValues.includes(val) ? "bg-blue-600 border-blue-600" : "border-slate-600 bg-slate-900 group-hover:border-slate-500"
)}>
{selectedValues.includes(val) && <Check className="w-3 h-3 text-white" />}
</div>
<input
type="checkbox"
className="hidden"
checked={selectedValues.includes(val)}
onChange={() => onToggle(val)}
/>
<span className="text-xs text-slate-300 truncate" title={val}>{val || '(Empty)'}</span>
</label>
))}
{filteredValues.length === 0 && (
<div className="text-[10px] text-slate-500 text-center py-4 italic">No values found</div>
)}
</div>
<div className="flex items-center justify-between pt-2 border-t border-slate-700 mt-1">
<button
onClick={onClear}
className="text-[10px] font-bold text-slate-400 hover:text-white transition-colors uppercase"
>
Clear
</button>
<button
onClick={onClose}
className="px-4 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-[10px] font-black rounded transition-colors shadow-lg active:scale-95"
>
OK
</button>
</div>
</div>
);
}
+112 -16
View File
@@ -1,8 +1,9 @@
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo } from 'react';
import { ExcelRow, COLUMNS } from '../types'; import { ExcelRow, COLUMNS } from '../types';
import { AlertTriangle, CheckCircle2, ChevronDown, ChevronRight, Edit2, Package, Boxes, Scale, Loader2, RefreshCw, Layers, Link2 } from 'lucide-react'; import { AlertTriangle, CheckCircle2, ChevronDown, ChevronRight, Edit2, Package, Boxes, Scale, Loader2, RefreshCw, Layers, Link2, Search, Filter, X as XIcon } from 'lucide-react';
import { cn } from '../lib/utils'; import { cn } from '../lib/utils';
import { ConfirmModal } from './ConfirmModal'; import { ConfirmModal } from './ConfirmModal';
import { ColumnFilterPopover } from './ColumnFilterPopover';
interface DimensionsViewProps { interface DimensionsViewProps {
data: ExcelRow[]; data: ExcelRow[];
@@ -48,6 +49,10 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
targetGroupKey: string; targetGroupKey: string;
selectedIndices: number[]; selectedIndices: number[];
} | null>(null); } | null>(null);
const [search, setSearch] = useState('');
const [lineFilter, setLineFilter] = useState<string[]>([]);
const [classFilter, setClassFilter] = useState<string[]>([]);
const [openFilter, setOpenFilter] = useState<'line' | 'class' | null>(null);
const groups = useMemo(() => { const groups = useMemo(() => {
const groupMap = new Map<string, { row: ExcelRow; index: number }[]>(); const groupMap = new Map<string, { row: ExcelRow; index: number }[]>();
@@ -113,8 +118,33 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
}, [data]); }, [data]);
const filteredGroups = useMemo(() => { const filteredGroups = useMemo(() => {
return showOnlyInconsistent ? groups.filter(g => g.isInconsistent) : groups; let result = groups;
}, [groups, showOnlyInconsistent]); if (showOnlyInconsistent) result = result.filter(g => g.isInconsistent);
if (search || lineFilter.length > 0 || classFilter.length > 0) {
const s = search.toLowerCase();
result = result.filter(g => {
const matchesSearch = !search || g.rows.some(({ row }) =>
String(row[COLUMNS.ARTICLE_NO] || '').toLowerCase().includes(s) ||
String(row[COLUMNS.ARTICLE_NAME] || '').toLowerCase().includes(s)
);
const matchesLine = lineFilter.length === 0 || g.rows.some(({ row }) => lineFilter.includes(String(row[COLUMNS.LINE] || '')));
const matchesClass = classFilter.length === 0 || g.rows.some(({ row }) => classFilter.includes(String(row[COLUMNS.CLASSIFICATION] || '')));
return matchesSearch && matchesLine && matchesClass;
});
}
return result;
}, [groups, showOnlyInconsistent, search, lineFilter, classFilter]);
const uniqueLines = useMemo(() =>
Array.from(new Set(data.map(r => String(r[COLUMNS.LINE] || '')))).sort()
, [data]);
const uniqueClasses = useMemo(() =>
Array.from(new Set(data.map(r => String(r[COLUMNS.CLASSIFICATION] || '')))).sort()
, [data]);
const nearDuplicateClusters = useMemo((): NearDuplicateCluster[] => { const nearDuplicateClusters = useMemo((): NearDuplicateCluster[] => {
// Two groups are "similar" if every sorted dimension pair differs by < 1 cm absolute // Two groups are "similar" if every sorted dimension pair differs by < 1 cm absolute
@@ -261,17 +291,83 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between bg-slate-800/50 p-4 rounded-lg border border-slate-700"> <div className="flex flex-wrap items-center gap-4 bg-slate-800/50 p-4 rounded-lg border border-slate-700">
<div> <div className="relative flex-1 min-w-[250px]">
<h2 className="text-xl font-semibold text-white flex items-center gap-2"> <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
<Boxes className="text-blue-400" /> <input
Dimension Consistency Check type="text"
</h2> placeholder="Search SKU or Name in groups..."
<p className="text-sm text-slate-400 mt-1"> value={search}
Grouping products by Inner Box dimensions to find Packaging or MOQ discrepancies. onChange={e => setSearch(e.target.value)}
</p> className="w-full pl-9 pr-4 py-2 bg-slate-900 border border-slate-700 rounded-md text-sm text-white focus:outline-none focus:border-blue-500"
/>
</div> </div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<div className="relative">
<button
onClick={() => setOpenFilter(openFilter === 'line' ? null : 'line')}
className={cn(
"flex items-center gap-2 px-3 py-2 rounded-md border text-sm transition-colors",
lineFilter.length > 0 ? "bg-blue-600/10 border-blue-500/50 text-blue-400" : "bg-slate-900 border-slate-700 text-slate-400 hover:border-slate-600"
)}
>
<Filter className="w-4 h-4" />
Line {lineFilter.length > 0 && `(${lineFilter.length})`}
</button>
{openFilter === 'line' && (
<ColumnFilterPopover
uniqueValues={uniqueLines}
selectedValues={lineFilter}
onToggle={val => setLineFilter(prev => prev.includes(val) ? prev.filter(v => v !== val) : [...prev, val])}
onSelectAll={setLineFilter}
onClear={() => setLineFilter([])}
onClose={() => setOpenFilter(null)}
title="Filter by Line"
className="left-auto right-0"
/>
)}
</div>
<div className="relative">
<button
onClick={() => setOpenFilter(openFilter === 'class' ? null : 'class')}
className={cn(
"flex items-center gap-2 px-3 py-2 rounded-md border text-sm transition-colors",
classFilter.length > 0 ? "bg-blue-600/10 border-blue-500/50 text-blue-400" : "bg-slate-900 border-slate-700 text-slate-400 hover:border-slate-600"
)}
>
<Filter className="w-4 h-4" />
Class {classFilter.length > 0 && `(${classFilter.length})`}
</button>
{openFilter === 'class' && (
<ColumnFilterPopover
uniqueValues={uniqueClasses}
selectedValues={classFilter}
onToggle={val => setClassFilter(prev => prev.includes(val) ? prev.filter(v => v !== val) : [...prev, val])}
onSelectAll={setClassFilter}
onClear={() => setClassFilter([])}
onClose={() => setOpenFilter(null)}
title="Filter by Classification"
className="left-auto right-0"
/>
)}
</div>
{(lineFilter.length > 0 || classFilter.length > 0 || search) && (
<button
onClick={() => { setSearch(''); setLineFilter([]); setClassFilter([]); }}
className="p-2 text-red-400 hover:text-red-300 transition-colors"
title="Clear all filters"
>
<XIcon className="w-5 h-5" />
</button>
)}
</div>
<div className="h-8 w-px bg-slate-700 mx-2 hidden sm:block" />
<div className="flex items-center gap-4 ml-auto">
<label className="flex items-center gap-2 text-sm text-slate-300 cursor-pointer"> <label className="flex items-center gap-2 text-sm text-slate-300 cursor-pointer">
<input <input
type="checkbox" type="checkbox"
@@ -279,10 +375,10 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
onChange={e => setShowOnlyInconsistent(e.target.checked)} onChange={e => setShowOnlyInconsistent(e.target.checked)}
className="rounded border-slate-600 bg-slate-700 text-blue-600 focus:ring-blue-500" className="rounded border-slate-600 bg-slate-700 text-blue-600 focus:ring-blue-500"
/> />
Show only inconsistent groups Show only inconsistent
</label> </label>
<div className="text-xs text-slate-500 bg-slate-900 px-3 py-1.5 rounded-full border border-slate-700"> <div className="text-[10px] font-bold text-amber-500 bg-amber-500/10 px-2 py-1 rounded border border-amber-500/20 whitespace-nowrap">
{groups.filter(g => g.isInconsistent).length} Inconsistencies found {groups.filter(g => g.isInconsistent).length} ISSUES
</div> </div>
</div> </div>
</div> </div>
+136 -8
View File
@@ -1,6 +1,8 @@
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo } from 'react';
import { Search } from 'lucide-react';
import { ExcelRow } from '../types'; import { ExcelRow } from '../types';
import { Search, Filter, ChevronDown, ChevronUp, X as XIcon } from 'lucide-react';
import { cn } from '../lib/utils';
import { ColumnFilterPopover } from './ColumnFilterPopover';
interface MatrixViewProps { interface MatrixViewProps {
data: ExcelRow[]; data: ExcelRow[];
@@ -11,20 +13,88 @@ export function MatrixView({ data, headers }: MatrixViewProps) {
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(25); const [pageSize, setPageSize] = useState(25);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [columnFilters, setColumnFilters] = useState<Record<number, string[]>>({});
const [openFilterCol, setOpenFilterCol] = useState<number | null>(null);
const [sortCol, setSortCol] = useState<number | null>(null);
const [sortDesc, setSortDesc] = useState(false);
const filteredData = useMemo(() => { const filteredData = useMemo(() => {
if (!search) return data; let result = data.map((row, index) => ({ row, index }));
// Global search
if (search) {
const s = search.toLowerCase(); const s = search.toLowerCase();
return data.filter(row => result = result.filter(r =>
row.some(cell => String(cell || '').toLowerCase().includes(s)) r.row.some(cell => String(cell || '').toLowerCase().includes(s))
); );
}, [data, search]); }
// Column-specific filters
Object.entries(columnFilters).forEach(([colIdx, selectedValues]) => {
const vals = selectedValues as string[];
if (vals.length > 0) {
result = result.filter(r => vals.includes(String(r.row[Number(colIdx)] || '')));
}
});
// Sorting
if (sortCol !== null) {
result.sort((a, b) => {
const valA = String(a.row[sortCol] || '').toLowerCase();
const valB = String(b.row[sortCol] || '').toLowerCase();
// Handle numbers sorting correctly
const numA = parseFloat(valA.replace(',', '.'));
const numB = parseFloat(valB.replace(',', '.'));
if (!isNaN(numA) && !isNaN(numB)) {
return sortDesc ? numB - numA : numA - numB;
}
return sortDesc
? valB.localeCompare(valA)
: valA.localeCompare(valB);
});
}
return result;
}, [data, search, columnFilters, sortCol, sortDesc]);
const paginatedData = useMemo(() => { const paginatedData = useMemo(() => {
const start = (page - 1) * pageSize; const start = (page - 1) * pageSize;
return filteredData.slice(start, start + pageSize); return filteredData.slice(start, start + pageSize);
}, [filteredData, page, pageSize]); }, [filteredData, page, pageSize]);
const handleSort = (col: number) => {
if (sortCol === col) {
setSortDesc(!sortDesc);
} else {
setSortCol(col);
setSortDesc(false);
}
};
const getUniqueValues = (col: number) => {
const values = data.map(r => String(r[col] || ''));
return Array.from(new Set(values)).sort();
};
const toggleColumnFilter = (col: number, value: string) => {
setColumnFilters(prev => {
const current = prev[col] || [];
const next = current.includes(value)
? current.filter(v => v !== value)
: [...current, value];
return { ...prev, [col]: next };
});
setPage(1);
};
const setBatchColumnFilter = (col: number, values: string[]) => {
setColumnFilters(prev => ({ ...prev, [col]: values }));
setPage(1);
};
const formatCellValue = (val: any, header: string = '') => { const formatCellValue = (val: any, header: string = '') => {
if (val === undefined || val === null || val === '') return ''; if (val === undefined || val === null || val === '') return '';
@@ -91,16 +161,30 @@ export function MatrixView({ data, headers }: MatrixViewProps) {
<h2 className="text-lg font-semibold text-white">Matrix View</h2> <h2 className="text-lg font-semibold text-white">Matrix View</h2>
<p className="text-sm text-slate-400">All data fields formatted to 2 decimal places for numbers/prices.</p> <p className="text-sm text-slate-400">All data fields formatted to 2 decimal places for numbers/prices.</p>
</div> </div>
<div className="flex items-center gap-4">
<div className="relative min-w-[300px]"> <div className="relative min-w-[300px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input <input
type="text" type="text"
placeholder="Search article, name, weight..." placeholder="Search all columns..."
value={search} value={search}
onChange={e => { setSearch(e.target.value); setPage(1); }} onChange={e => { setSearch(e.target.value); setPage(1); }}
className="w-full pl-9 pr-4 py-2 bg-slate-900 border border-slate-700 rounded-md text-sm text-white focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-all font-medium" className="w-full pl-9 pr-4 py-2 bg-slate-900 border border-slate-700 rounded-md text-sm text-white focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-all font-medium"
/> />
</div> </div>
{(Object.values(columnFilters) as string[][]).some(v => v.length > 0) && (
<button
onClick={() => {
setColumnFilters({});
setPage(1);
}}
className="flex items-center gap-2 px-4 py-2 bg-red-600/10 text-red-400 hover:bg-red-600 hover:text-white rounded-md text-sm font-medium transition-colors border border-red-600/20"
>
<XIcon className="w-4 h-4" />
Clear All Column Filters
</button>
)}
</div>
</div> </div>
<div className="overflow-auto flex-1"> <div className="overflow-auto flex-1">
@@ -108,14 +192,58 @@ export function MatrixView({ data, headers }: MatrixViewProps) {
<thead className="bg-slate-900/50 text-slate-400 sticky top-0 z-10"> <thead className="bg-slate-900/50 text-slate-400 sticky top-0 z-10">
<tr> <tr>
{headers.map((header, index) => ( {headers.map((header, index) => (
<th key={index} className="px-4 py-3 font-medium border-b border-slate-700"> <th
key={index}
className="px-4 py-3 font-medium border-b border-slate-700 transition-colors select-none group relative"
>
<div className="flex items-center justify-between gap-2">
<div
className="flex items-center gap-1 cursor-pointer hover:text-white"
onClick={() => handleSort(index)}
>
{header} {header}
{sortCol === index && (
sortDesc ? <ChevronDown className="w-3 h-3" /> : <ChevronUp className="w-3 h-3" />
)}
</div>
<button
onClick={(e) => {
e.stopPropagation();
setOpenFilterCol(openFilterCol === index ? null : index);
}}
className={cn(
"p-1 rounded hover:bg-slate-700 transition-colors",
(columnFilters[index]?.length || 0) > 0 ? "text-blue-400 bg-blue-400/10 opacity-100" : "text-slate-500 opacity-0 group-hover:opacity-100"
)}
>
<Filter className="w-3.5 h-3.5" />
</button>
</div>
{openFilterCol === index && (
<ColumnFilterPopover
uniqueValues={getUniqueValues(index)}
selectedValues={columnFilters[index] || []}
onToggle={(val) => toggleColumnFilter(index, val)}
onSelectAll={(vals) => setBatchColumnFilter(index, vals)}
onClear={() => {
setColumnFilters(prev => {
const next = { ...prev };
delete next[index];
return next;
});
setOpenFilterCol(null);
}}
onClose={() => setOpenFilterCol(null)}
title={header}
/>
)}
</th> </th>
))} ))}
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-slate-700/50"> <tbody className="divide-y divide-slate-700/50">
{paginatedData.map((row, rowIndex) => ( {paginatedData.map(({ row, index: rowIndex }) => (
<tr key={rowIndex} className="hover:bg-slate-700/30 transition-colors"> <tr key={rowIndex} className="hover:bg-slate-700/30 transition-colors">
{headers.map((header, colIndex) => { {headers.map((header, colIndex) => {
const formattedValue = formatCellValue(row[colIndex], header); const formattedValue = formatCellValue(row[colIndex], header);
+1 -74
View File
@@ -15,6 +15,7 @@ import {
Search, Search,
} from 'lucide-react'; } from 'lucide-react';
import { cn } from '../lib/utils'; import { cn } from '../lib/utils';
import { ColumnFilterPopover } from './ColumnFilterPopover';
interface PricingViewProps { interface PricingViewProps {
data: ExcelRow[]; data: ExcelRow[];
@@ -648,80 +649,6 @@ function TextFilterPopover({ value, onChange, onClose }: {
); );
} }
// ── Multi-select column filter popover ────────────────────────────────────────
function ColumnFilterPopover({ uniqueValues, selectedValues, onToggle, onSelectAll, onClear, onClose }: {
uniqueValues: string[];
selectedValues: string[];
onToggle: (val: string) => void;
onSelectAll: (vals: string[]) => void;
onClear: () => void;
onClose: () => void;
}) {
const [search, setSearch] = useState('');
const filtered = uniqueValues.filter(v => v.toLowerCase().includes(search.toLowerCase()));
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="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-slate-500" />
<input
type="text"
placeholder="Filter values…"
value={search}
onChange={e => setSearch(e.target.value)}
autoFocus
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"
/>
</div>
<div className="flex items-center justify-between px-1">
<label className="flex items-center gap-2 py-1 px-1.5 hover:bg-slate-700/50 rounded cursor-pointer group">
<div className={cn(
"w-4 h-4 rounded border flex items-center justify-center shrink-0 transition-colors",
selectedValues.length === uniqueValues.length ? "bg-blue-600 border-blue-600" : "border-slate-600 bg-slate-900 group-hover:border-slate-500"
)}>
{selectedValues.length === uniqueValues.length && <Check className="w-3 h-3 text-white" />}
</div>
<input
type="checkbox"
className="hidden"
checked={selectedValues.length === uniqueValues.length}
onChange={() => {
if (selectedValues.length === uniqueValues.length) {
onSelectAll([]);
} else {
onSelectAll(uniqueValues);
}
}}
/>
<span className="text-[11px] font-semibold text-slate-300">Select All</span>
</label>
<span className="text-[10px] text-slate-500">{selectedValues.length} selected</span>
</div>
<div className="max-h-48 overflow-y-auto space-y-0.5">
{filtered.map(val => (
<label key={val} className="flex items-center gap-2 px-1.5 py-1 hover:bg-slate-700/50 rounded cursor-pointer group">
<div className={cn(
'w-4 h-4 rounded border flex items-center justify-center shrink-0 transition-colors',
selectedValues.includes(val) ? 'bg-blue-600 border-blue-600' : 'border-slate-600 bg-slate-900 group-hover:border-slate-500'
)}>
{selectedValues.includes(val) && <Check className="w-3 h-3 text-white" />}
</div>
<input type="checkbox" className="hidden" checked={selectedValues.includes(val)} onChange={() => onToggle(val)} />
<span className="text-xs text-slate-300 truncate" title={val}>{val || '(Empty)'}</span>
</label>
))}
{filtered.length === 0 && <p className="text-[10px] text-slate-500 text-center py-2">No values</p>}
</div>
<div className="flex items-center justify-between pt-1 border-t border-slate-700">
<button onClick={onClear} className="text-[10px] font-medium text-slate-400 hover:text-white transition-colors">Clear</button>
<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>
</div>
</div>
);
}
// ── Stat card ───────────────────────────────────────────────────────────────── // ── Stat card ─────────────────────────────────────────────────────────────────
function StatCard({ function StatCard({
label, label,
+2 -97
View File
@@ -1,7 +1,8 @@
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo } from 'react';
import { ExcelRow, COLUMNS } from '../types'; import { ExcelRow, COLUMNS } from '../types';
import { Search, Filter, Edit2, ChevronDown, ChevronUp, X, Check } from 'lucide-react'; import { Search, Filter, Edit2, ChevronDown, ChevronUp, X } from 'lucide-react';
import { cn } from '../lib/utils'; import { cn } from '../lib/utils';
import { ColumnFilterPopover } from './ColumnFilterPopover';
interface ProductDescriptionsProps { interface ProductDescriptionsProps {
data: ExcelRow[]; data: ExcelRow[];
@@ -361,99 +362,3 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
</div> </div>
); );
} }
interface ColumnFilterPopoverProps {
uniqueValues: string[];
selectedValues: string[];
onToggle: (val: string) => void;
onSelectAll: (vals: string[]) => void;
onClear: () => void;
onClose: () => void;
}
function ColumnFilterPopover({ uniqueValues, selectedValues, onToggle, onSelectAll, onClear, onClose }: ColumnFilterPopoverProps) {
const [search, setSearch] = useState('');
const filteredValues = useMemo(() => {
return uniqueValues.filter(v => v.toLowerCase().includes(search.toLowerCase()));
}, [uniqueValues, search]);
return (
<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-3 flex flex-col gap-3 animate-in fade-in zoom-in-95 duration-100">
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-slate-500" />
<input
type="text"
placeholder="Filter values..."
value={search}
onChange={e => setSearch(e.target.value)}
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"
autoFocus
/>
</div>
<div className="flex items-center justify-between px-1">
<label className="flex items-center gap-2 py-1 px-1.5 hover:bg-slate-700/50 rounded cursor-pointer group">
<div className={cn(
"w-4 h-4 rounded border flex items-center justify-center transition-colors",
selectedValues.length === uniqueValues.length ? "bg-blue-600 border-blue-600" : "border-slate-600 bg-slate-900 group-hover:border-slate-500"
)}>
{selectedValues.length === uniqueValues.length && <Check className="w-3 h-3 text-white" />}
</div>
<input
type="checkbox"
className="hidden"
checked={selectedValues.length === uniqueValues.length}
onChange={() => {
if (selectedValues.length === uniqueValues.length) {
onSelectAll([]);
} else {
onSelectAll(uniqueValues);
}
}}
/>
<span className="text-[11px] font-semibold text-slate-300">Select All</span>
</label>
<span className="text-[10px] text-slate-500">{selectedValues.length} selected</span>
</div>
<div className="max-h-48 overflow-y-auto space-y-1 pr-1 custom-scrollbar">
{filteredValues.map(val => (
<label key={val} className="flex items-center gap-2 p-1.5 hover:bg-slate-700/50 rounded cursor-pointer group">
<div className={cn(
"w-4 h-4 rounded border flex items-center justify-center transition-colors",
selectedValues.includes(val) ? "bg-blue-600 border-blue-600" : "border-slate-600 bg-slate-900 group-hover:border-slate-500"
)}>
{selectedValues.includes(val) && <Check className="w-3 h-3 text-white" />}
</div>
<input
type="checkbox"
className="hidden"
checked={selectedValues.includes(val)}
onChange={() => onToggle(val)}
/>
<span className="text-xs text-slate-300 truncate" title={val}>{val || '(Empty)'}</span>
</label>
))}
{filteredValues.length === 0 && (
<div className="text-[10px] text-slate-500 text-center py-2">No values found</div>
)}
</div>
<div className="flex items-center justify-between pt-2 border-t border-slate-700 mt-1">
<button
onClick={onClear}
className="text-[10px] font-medium text-slate-400 hover:text-white transition-colors"
>
Clear Current
</button>
<button
onClick={onClose}
className="px-3 py-1 bg-blue-600 hover:bg-blue-700 text-white text-[10px] font-bold rounded transition-colors shadow-sm"
>
OK
</button>
</div>
</div>
);
}