From 89aa22247043587b0f0caad1393b85e5085c2c1f Mon Sep 17 00:00:00 2001 From: Christian Vidal Wolf Date: Wed, 8 Apr 2026 16:56:43 +0200 Subject: [PATCH] feat: standardize ColumnFilterPopover and add SELECT ALL to all views/tabs --- src/components/ArticleDetails.tsx | 99 +-------------- src/components/ColumnFilterPopover.tsx | 119 ++++++++++++++++++ src/components/DimensionsView.tsx | 128 ++++++++++++++++--- src/components/MatrixView.tsx | 166 ++++++++++++++++++++++--- src/components/PricingView.tsx | 75 +---------- src/components/ProductDescriptions.tsx | 99 +-------------- 6 files changed, 383 insertions(+), 303 deletions(-) create mode 100644 src/components/ColumnFilterPopover.tsx diff --git a/src/components/ArticleDetails.tsx b/src/components/ArticleDetails.tsx index 1456378..9af3a83 100644 --- a/src/components/ArticleDetails.tsx +++ b/src/components/ArticleDetails.tsx @@ -1,7 +1,8 @@ import React, { useState, useMemo } from 'react'; 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 { ColumnFilterPopover } from './ColumnFilterPopover'; interface ArticleDetailsProps { data: ExcelRow[]; @@ -319,99 +320,3 @@ export function ArticleDetails({ data, onEdit }: ArticleDetailsProps) { ); } - -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 ( -
-
- - 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 - /> -
- -
- - {selectedValues.length} selected -
- -
- {filteredValues.map(val => ( - - ))} - {filteredValues.length === 0 && ( -
No values found
- )} -
- -
- - -
-
- ); -} diff --git a/src/components/ColumnFilterPopover.tsx b/src/components/ColumnFilterPopover.tsx new file mode 100644 index 0000000..faeb128 --- /dev/null +++ b/src/components/ColumnFilterPopover.tsx @@ -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 ( +
+ {title &&
{title}
} + +
+ + 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 + /> +
+ +
+ + {selectedValues.length} selected +
+ +
+ {filteredValues.map(val => ( + + ))} + {filteredValues.length === 0 && ( +
No values found
+ )} +
+ +
+ + +
+
+ ); +} diff --git a/src/components/DimensionsView.tsx b/src/components/DimensionsView.tsx index 31b49ac..837b828 100644 --- a/src/components/DimensionsView.tsx +++ b/src/components/DimensionsView.tsx @@ -1,8 +1,9 @@ import React, { useState, useMemo } from 'react'; 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 { ConfirmModal } from './ConfirmModal'; +import { ColumnFilterPopover } from './ColumnFilterPopover'; interface DimensionsViewProps { data: ExcelRow[]; @@ -48,6 +49,10 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat targetGroupKey: string; selectedIndices: number[]; } | null>(null); + const [search, setSearch] = useState(''); + const [lineFilter, setLineFilter] = useState([]); + const [classFilter, setClassFilter] = useState([]); + const [openFilter, setOpenFilter] = useState<'line' | 'class' | null>(null); const groups = useMemo(() => { const groupMap = new Map(); @@ -113,8 +118,33 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat }, [data]); const filteredGroups = useMemo(() => { - return showOnlyInconsistent ? groups.filter(g => g.isInconsistent) : groups; - }, [groups, showOnlyInconsistent]); + let result = groups; + 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[] => { // 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 (
-
-
-

- - Dimension Consistency Check -

-

- Grouping products by Inner Box dimensions to find Packaging or MOQ discrepancies. -

+
+
+ + setSearch(e.target.value)} + 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" + />
-
+ +
+
+ + {openFilter === 'line' && ( + 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" + /> + )} +
+ +
+ + {openFilter === 'class' && ( + 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" + /> + )} +
+ + {(lineFilter.length > 0 || classFilter.length > 0 || search) && ( + + )} +
+ +
+ +
-
- {groups.filter(g => g.isInconsistent).length} Inconsistencies found +
+ {groups.filter(g => g.isInconsistent).length} ISSUES
diff --git a/src/components/MatrixView.tsx b/src/components/MatrixView.tsx index 358afdb..6e00438 100644 --- a/src/components/MatrixView.tsx +++ b/src/components/MatrixView.tsx @@ -1,6 +1,8 @@ import React, { useState, useMemo } from 'react'; -import { Search } from 'lucide-react'; 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 { data: ExcelRow[]; @@ -11,20 +13,88 @@ export function MatrixView({ data, headers }: MatrixViewProps) { const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(25); const [search, setSearch] = useState(''); + const [columnFilters, setColumnFilters] = useState>({}); + const [openFilterCol, setOpenFilterCol] = useState(null); + const [sortCol, setSortCol] = useState(null); + const [sortDesc, setSortDesc] = useState(false); const filteredData = useMemo(() => { - if (!search) return data; - const s = search.toLowerCase(); - return data.filter(row => - row.some(cell => String(cell || '').toLowerCase().includes(s)) - ); - }, [data, search]); + let result = data.map((row, index) => ({ row, index })); + + // Global search + if (search) { + const s = search.toLowerCase(); + 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 start = (page - 1) * pageSize; return filteredData.slice(start, start + 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 = '') => { if (val === undefined || val === null || val === '') return ''; @@ -91,15 +161,29 @@ export function MatrixView({ data, headers }: MatrixViewProps) {

Matrix View

All data fields formatted to 2 decimal places for numbers/prices.

-
- - { 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" - /> +
+
+ + { 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" + /> +
+ {(Object.values(columnFilters) as string[][]).some(v => v.length > 0) && ( + + )}
@@ -108,14 +192,58 @@ export function MatrixView({ data, headers }: MatrixViewProps) { {headers.map((header, index) => ( - - {header} + +
+
handleSort(index)} + > + {header} + {sortCol === index && ( + sortDesc ? : + )} +
+ +
+ + {openFilterCol === index && ( + 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} + /> + )} ))} - {paginatedData.map((row, rowIndex) => ( + {paginatedData.map(({ row, index: rowIndex }) => ( {headers.map((header, colIndex) => { const formattedValue = formatCellValue(row[colIndex], header); diff --git a/src/components/PricingView.tsx b/src/components/PricingView.tsx index a4baedb..d9585ef 100644 --- a/src/components/PricingView.tsx +++ b/src/components/PricingView.tsx @@ -15,6 +15,7 @@ import { Search, } from 'lucide-react'; import { cn } from '../lib/utils'; +import { ColumnFilterPopover } from './ColumnFilterPopover'; interface PricingViewProps { 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 ( -
-
- - 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" - /> -
- -
- - {selectedValues.length} selected -
- -
- {filtered.map(val => ( - - ))} - {filtered.length === 0 &&

No values

} -
-
- - -
-
- ); -} - // ── Stat card ───────────────────────────────────────────────────────────────── function StatCard({ label, diff --git a/src/components/ProductDescriptions.tsx b/src/components/ProductDescriptions.tsx index 4e0972f..6c9773c 100644 --- a/src/components/ProductDescriptions.tsx +++ b/src/components/ProductDescriptions.tsx @@ -1,7 +1,8 @@ import React, { useState, useMemo } from 'react'; 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 { ColumnFilterPopover } from './ColumnFilterPopover'; interface ProductDescriptionsProps { data: ExcelRow[]; @@ -361,99 +362,3 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
); } - -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 ( -
-
- - 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 - /> -
- -
- - {selectedValues.length} selected -
- -
- {filteredValues.map(val => ( - - ))} - {filteredValues.length === 0 && ( -
No values found
- )} -
- -
- - -
-
- ); -}