mirror of
https://github.com/christianvidalwolf-prog/Craze-Data-check.git
synced 2026-08-03 13:05:25 +02:00
feat: standardize ColumnFilterPopover and add SELECT ALL to all views/tabs
This commit is contained in:
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
+147
-19
@@ -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 }));
|
||||||
const s = search.toLowerCase();
|
|
||||||
return data.filter(row =>
|
// Global search
|
||||||
row.some(cell => String(cell || '').toLowerCase().includes(s))
|
if (search) {
|
||||||
);
|
const s = search.toLowerCase();
|
||||||
}, [data, search]);
|
result = result.filter(r =>
|
||||||
|
r.row.some(cell => String(cell || '').toLowerCase().includes(s))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,15 +161,29 @@ 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="relative min-w-[300px]">
|
<div className="flex items-center gap-4">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
<div className="relative min-w-[300px]">
|
||||||
<input
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||||
type="text"
|
<input
|
||||||
placeholder="Search article, name, weight..."
|
type="text"
|
||||||
value={search}
|
placeholder="Search all columns..."
|
||||||
onChange={e => { setSearch(e.target.value); setPage(1); }}
|
value={search}
|
||||||
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"
|
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"
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
|
||||||
@@ -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
|
||||||
{header}
|
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}
|
||||||
|
{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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user