From 917bb08d9feb46821039139828ce08e0278660c1 Mon Sep 17 00:00:00 2001 From: Christian Vidal Wolf Date: Fri, 24 Apr 2026 14:19:09 +0200 Subject: [PATCH] feat: persistent search and filters across all tabs via FilterContext --- src/App.tsx | 5 +- src/components/DimensionsView.tsx | 7 +-- src/components/HistoryView.tsx | 3 +- src/components/MatrixView.tsx | 9 ++-- src/components/MissingDataView.tsx | 13 ++--- src/components/PendingValidationView.tsx | 3 +- src/components/PricingView.tsx | 68 +++++++++++++----------- src/components/ProductDescriptions.tsx | 17 +++--- src/contexts/FilterContext.tsx | 40 ++++++++++++++ 9 files changed, 111 insertions(+), 54 deletions(-) create mode 100644 src/contexts/FilterContext.tsx diff --git a/src/App.tsx b/src/App.tsx index e3567cc..61ce8fb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,7 @@ import { X } from 'lucide-react'; import { cn } from './lib/utils'; import { AppState, ExcelRow, resolveColumnIndices, COLUMNS } from './types'; import { ColumnsProvider } from './contexts/ColumnsContext'; +import { FilterProvider } from './contexts/FilterContext'; import { Sidebar } from './components/Sidebar'; import { TopBar } from './components/TopBar'; import { ProductDescriptions } from './components/ProductDescriptions'; @@ -618,7 +619,8 @@ const articleNoIdx = resolvedCols.ARTICLE_NO; return ( -
+ +
{!isMaximized && ( )}
+
); } diff --git a/src/components/DimensionsView.tsx b/src/components/DimensionsView.tsx index 7dec711..f7bbc86 100644 --- a/src/components/DimensionsView.tsx +++ b/src/components/DimensionsView.tsx @@ -5,6 +5,7 @@ import { AlertTriangle, CheckCircle2, ChevronDown, ChevronRight, Edit2, Package, import { cn } from '../lib/utils'; import { ConfirmModal } from './ConfirmModal'; import { ColumnFilterPopover } from './ColumnFilterPopover'; +import { usePersistentState } from '../contexts/FilterContext'; interface DimensionsViewProps { data: ExcelRow[]; @@ -53,9 +54,9 @@ 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 [search, setSearch] = usePersistentState('global-search', ''); + const [lineFilter, setLineFilter] = usePersistentState('dimensions-lineFilter', []); + const [classFilter, setClassFilter] = usePersistentState('dimensions-classFilter', []); const [openFilter, setOpenFilter] = useState<'line' | 'class' | null>(null); const [isDragging, setIsDragging] = useState(false); const [dragStart, setDragStart] = useState(null); diff --git a/src/components/HistoryView.tsx b/src/components/HistoryView.tsx index e32994e..7a2806b 100644 --- a/src/components/HistoryView.tsx +++ b/src/components/HistoryView.tsx @@ -4,6 +4,7 @@ import { getHistory, deleteHistoryEntry, HistoryEntry } from '../lib/supabase'; import { ExcelRow } from '../types'; import { useColumns } from '../contexts/ColumnsContext'; import { cn } from '../lib/utils'; +import { usePersistentState } from '../contexts/FilterContext'; interface HistoryViewProps { headers: string[]; @@ -17,7 +18,7 @@ export function HistoryView({ headers, data, onRevert, onEdit }: HistoryViewProp const [history, setHistory] = useState([]); const [loading, setLoading] = useState(true); const [expandedId, setExpandedId] = useState(null); - const [search, setSearch] = useState(''); + const [search, setSearch] = usePersistentState('global-search', ''); const [isFullscreen, setIsFullscreen] = useState(false); useEffect(() => { diff --git a/src/components/MatrixView.tsx b/src/components/MatrixView.tsx index 03a6094..e511286 100644 --- a/src/components/MatrixView.tsx +++ b/src/components/MatrixView.tsx @@ -3,6 +3,7 @@ import { ExcelRow } from '../types'; import { Search, Filter, ChevronDown, ChevronUp, X, Maximize2 } from 'lucide-react'; import { cn } from '../lib/utils'; import { ColumnFilterPopover } from './ColumnFilterPopover'; +import { usePersistentState } from '../contexts/FilterContext'; interface MatrixViewProps { data: ExcelRow[]; @@ -13,11 +14,11 @@ interface MatrixViewProps { export function MatrixView({ data, headers, rowStatuses }: MatrixViewProps) { const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(25); - const [search, setSearch] = useState(''); - const [columnFilters, setColumnFilters] = useState>({}); + const [search, setSearch] = usePersistentState('global-search', ''); + const [columnFilters, setColumnFilters] = usePersistentState>('matrix-columnFilters', {}); const [openFilterCol, setOpenFilterCol] = useState(null); - const [sortCol, setSortCol] = useState(null); - const [sortDesc, setSortDesc] = useState(false); + const [sortCol, setSortCol] = usePersistentState('matrix-sortCol', null); + const [sortDesc, setSortDesc] = usePersistentState('matrix-sortDesc', false); const [isFullscreen, setIsFullscreen] = useState(false); const filteredData = useMemo(() => { diff --git a/src/components/MissingDataView.tsx b/src/components/MissingDataView.tsx index c4ce600..4d79599 100644 --- a/src/components/MissingDataView.tsx +++ b/src/components/MissingDataView.tsx @@ -5,6 +5,7 @@ import { Search, ChevronDown, ChevronUp, X, Edit2, Save, Maximize2 } from 'lucid import { cn } from '../lib/utils'; import { ColumnFilterPopover } from './ColumnFilterPopover'; import { DateFilterPopover } from './DateFilterPopover'; +import { usePersistentState } from '../contexts/FilterContext'; interface MissingDataViewProps { data: ExcelRow[]; @@ -67,14 +68,14 @@ interface EditingState { export function MissingDataView({ data, headers, onSaveRow, onCaptureState }: MissingDataViewProps) { const COLUMNS = useColumns(); - const [activeTab, setActiveTab] = useState('missingLaunch'); - const [search, setSearch] = useState(''); - const [sortCol, setSortCol] = useState(null); - const [sortDesc, setSortDesc] = useState(false); + const [activeTab, setActiveTab] = usePersistentState('missingData-tab', 'missingLaunch'); + const [search, setSearch] = usePersistentState('global-search', ''); + const [sortCol, setSortCol] = usePersistentState('missingData-sortCol', null); + const [sortDesc, setSortDesc] = usePersistentState('missingData-sortDesc', false); const [page, setPage] = useState(1); const [editing, setEditing] = useState(null); - const [columnFilters, setColumnFilters] = useState>({}); - const [dateFilters, setDateFilters] = useState>({}); + const [columnFilters, setColumnFilters] = usePersistentState>('missingData-columnFilters', {}); + const [dateFilters, setDateFilters] = usePersistentState>('missingData-dateFilters', {}); const [openFilter, setOpenFilter] = useState(null); const [isFullscreen, setIsFullscreen] = useState(false); const pageSize = 100; diff --git a/src/components/PendingValidationView.tsx b/src/components/PendingValidationView.tsx index b74f5ec..b8a1873 100644 --- a/src/components/PendingValidationView.tsx +++ b/src/components/PendingValidationView.tsx @@ -3,6 +3,7 @@ import { ExcelRow } from '../types'; import { useColumns } from '../contexts/ColumnsContext'; import { Clock, Undo2, Package, Box, DollarSign, FileText, Search, Filter, X, Maximize2 } from 'lucide-react'; import { cn } from '../lib/utils'; +import { usePersistentState } from '../contexts/FilterContext'; interface PendingValidationViewProps { data: ExcelRow[]; @@ -14,7 +15,7 @@ interface PendingValidationViewProps { export function PendingValidationView({ data, pendingRows, rowStatuses, onRevertRow, onEdit }: PendingValidationViewProps) { const COLUMNS = useColumns(); - const [search, setSearch] = useState(''); + const [search, setSearch] = usePersistentState('global-search', ''); const [isFullscreen, setIsFullscreen] = useState(false); const pendingEntries = Object.entries(pendingRows); diff --git a/src/components/PricingView.tsx b/src/components/PricingView.tsx index ea834f6..b70a348 100644 --- a/src/components/PricingView.tsx +++ b/src/components/PricingView.tsx @@ -21,6 +21,7 @@ import { } from 'lucide-react'; import { cn } from '../lib/utils'; import { ColumnFilterPopover } from './ColumnFilterPopover'; +import { usePersistentState } from '../contexts/FilterContext'; interface PricingViewProps { data: ExcelRow[]; @@ -53,8 +54,8 @@ function findCol(headers: string[], ...keywords: string[]): number { export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit, rowStatuses }: PricingViewProps) { const COLUMNS = useColumns(); - const [filterMode, setFilterMode] = useState('all_errors'); - const [search, setSearch] = useState(''); + const [filterMode, setFilterMode] = usePersistentState('pricing-filterMode', 'all_errors'); + const [search, setSearch] = usePersistentState('global-search', ''); const [currentPage, setCurrentPage] = useState(1); const pageSize = 100; const [columnWidths, setColumnWidths] = useState>({ @@ -78,33 +79,33 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit, const [editingCell, setEditingCell] = useState(null); const [savingCell, setSavingCell] = useState<{ rowIndex: number; colIndex: number } | null>(null); const inputRef = useRef(null); - const [lineMultiFilter, setLineMultiFilter] = useState([]); - const [classificationFilter, setClassificationFilter] = useState([]); - const [productTypeFilter, setProductTypeFilter] = useState([]); - const [nameColFilter, setNameColFilter] = useState<{ terms: string[]; op: 'and' | 'or' }>({ terms: [''], op: 'and' }); - const [articleNoColFilter, setArticleNoColFilter] = useState<{ terms: string[]; op: 'and' | 'or' }>({ terms: [''], op: 'and' }); - const [globalAdvancedFilter, setGlobalAdvancedFilter] = useState<{ terms: string[]; op: 'and' | 'or' }>({ terms: [''], op: 'and' }); - const [openFilter, setOpenFilter] = useState(null); - const [unitsOuterFilter, setUnitsOuterFilter] = useState([]); - const [outerWFilter, setOuterWFilter] = useState([]); - const [outerLFilter, setOuterLFilter] = useState([]); - const [outerHFilter, setOuterHFilter] = useState([]); - const [dynamicColFilters, setDynamicColFilters] = useState>({}); - const [weightIssueFilter, setWeightIssueFilter] = useState<'all' | 'with' | 'without'>('all'); const [editingType, setEditingType] = useState<{ rowIndex: number; value: string } | null>(null); const [editingLogistic, setEditingLogistic] = useState<{ rowIndex: number; value: string } | null>(null); const [typeSuggestions, setTypeSuggestions] = useState([]); const typeInputRef = useRef(null); const logisticInputRef = useRef(null); - const [sortConfig, setSortConfig] = useState<{ key: string | number; direction: 'asc' | 'desc' | null }>({ key: null, direction: null }); + const [sortConfig, setSortConfig] = usePersistentState<{ key: string | number; direction: 'asc' | 'desc' | null }>('pricing-sortConfig', { key: null, direction: null }); const [isFullscreen, setIsFullscreen] = useState(false); + const [lineMultiFilter, setLineMultiFilter] = usePersistentState('pricing-lineFilter', []); + const [classificationFilter, setClassificationFilter] = usePersistentState('pricing-classificationFilter', []); + const [productTypeFilter, setProductTypeFilter] = usePersistentState('pricing-productTypeFilter', []); + const [nameColFilter, setNameColFilter] = usePersistentState<{ terms: string[]; op: 'and' | 'or' }>('pricing-nameColFilter', { terms: [''], op: 'and' }); + const [articleNoColFilter, setArticleNoColFilter] = usePersistentState<{ terms: string[]; op: 'and' | 'or' }>('pricing-articleNoColFilter', { terms: [''], op: 'and' }); + const [globalAdvancedFilter, setGlobalAdvancedFilter] = usePersistentState<{ terms: string[]; op: 'and' | 'or' }>('pricing-globalAdvancedFilter', { terms: [''], op: 'and' }); + const [unitsOuterFilter, setUnitsOuterFilter] = usePersistentState('pricing-unitsOuterFilter', []); + const [outerWFilter, setOuterWFilter] = usePersistentState('pricing-outerWFilter', []); + const [outerLFilter, setOuterLFilter] = usePersistentState('pricing-outerLFilter', []); + const [outerHFilter, setOuterHFilter] = usePersistentState('pricing-outerHFilter', []); + const [dynamicColFilters, setDynamicColFilters] = usePersistentState>('pricing-dynamicColFilters', {}); + const [weightIssueFilter, setWeightIssueFilter] = usePersistentState<'all' | 'with' | 'without'>('pricing-weightIssueFilter', 'all'); + const [selectedSearchItems, setSelectedSearchItems] = usePersistentState>('pricing-selectedSearchItems', new Set()); + const [openFilter, setOpenFilter] = useState(null); const [isSearchOpen, setIsSearchOpen] = useState(false); - const [selectedSearchItems, setSelectedSearchItems] = useState>(new Set()); const searchDropdownRef = useRef(null); // Pinned columns state - const [pinnedColumns, setPinnedColumns] = useState>(new Set(['articleNo', 'articleName'])); + const [pinnedColumns, setPinnedColumns] = usePersistentState>('pricing-pinnedColumns', new Set(['articleNo', 'articleName'])); const [showPinPanel, setShowPinPanel] = useState(false); // Close search dropdown on click outside @@ -631,7 +632,7 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit, pricingEditableCols.forEach(col => dynamicOrder.push(`prc_${col.index}`)); containerCols.forEach(col => dynamicOrder.push(`con_${col.index}`)); const fullOrder = [...baseOrder, ...dynamicOrder]; - + const pinnedOrder = fullOrder.filter(k => pinnedColumns.has(k)); return (key: string): number | null => { if (!pinnedColumns.has(key)) return null; @@ -641,6 +642,13 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit, }; }, [pinnedColumns, pricingEditableCols, containerCols]); + // Header pinned cells need a stacking context above ALL body pinned cells + // so filter popovers (rendered inside ) appear over sticky body columns. + const getHeaderStickyRank = (key: string): number | null => { + const rank = getStickyRank(key); + return rank === null ? null : rank + 100; + }; + const isPinned = (key: string) => pinnedColumns.has(key); // ═ Unique values for dynamic pricing columns ═════════════════════════════ @@ -1143,7 +1151,7 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit, - - - - - - - -
handleSort('articleNo')}> @@ -1172,7 +1180,7 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit, )} { e.stopPropagation(); handleResizeStart(e, 'articleNo', columnWidths.articleNo); }} /> handleSort('articleName')}> @@ -1200,7 +1208,7 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit, )} { e.stopPropagation(); handleResizeStart(e, 'articleName', columnWidths.articleName); }} /> handleSort('line')}> @@ -1231,7 +1239,7 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit, )} { e.stopPropagation(); handleResizeStart(e, 'line', columnWidths.line); }} /> handleSort('classification')}> @@ -1263,7 +1271,7 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit, { e.stopPropagation(); handleResizeStart(e, 'classification', columnWidths.classification); }} /> handleSort('productType')}> @@ -1291,7 +1299,7 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit, { e.stopPropagation(); handleResizeStart(e, 'productType', columnWidths.productType); }} /> handleSort('itemToLogistic')}> @@ -1305,7 +1313,7 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit, const colKey = `prc_${col.index}`; const width = columnWidths[colKey] ?? 100; return ( - handleSort(col.index)}> @@ -1347,7 +1355,7 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit, ); })} - handleSort('unitsOuter')}> @@ -1379,7 +1387,7 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit, { e.stopPropagation(); handleResizeStart(e, 'unitsOuter', columnWidths.unitsOuter); }} /> handleSort('outerW')}> @@ -1411,7 +1419,7 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit, { e.stopPropagation(); handleResizeStart(e, 'outerW', columnWidths.outerW); }} /> handleSort('outerL')}> @@ -1479,7 +1487,7 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit, const colKey = `con_${col.index}`; const width = columnWidths[colKey] ?? 110; return ( - handleSort(col.index)}> diff --git a/src/components/ProductDescriptions.tsx b/src/components/ProductDescriptions.tsx index 17c684e..973a613 100644 --- a/src/components/ProductDescriptions.tsx +++ b/src/components/ProductDescriptions.tsx @@ -4,6 +4,7 @@ import { useColumns } from '../contexts/ColumnsContext'; import { Search, Filter, Edit2, ChevronDown, ChevronUp, X, Maximize2 } from 'lucide-react'; import { cn } from '../lib/utils'; import { ColumnFilterPopover } from './ColumnFilterPopover'; +import { usePersistentState } from '../contexts/FilterContext'; interface ProductDescriptionsProps { data: ExcelRow[]; @@ -19,15 +20,15 @@ export function ProductDescriptions({ data, headers, asinColumnIndex, onEdit, ro // Description columns that should only have Present/Missing filters const DESCRIPTION_COLUMNS = [COLUMNS.LONG_DE, COLUMNS.LONG_EN, COLUMNS.SHORT_DE, COLUMNS.SHORT_EN, COLUMNS.DETAILS_DE, COLUMNS.DETAILS_EN]; - const [activeTab, setActiveTab] = useState('all'); - const [search, setSearch] = useState(''); - const [lineFilter, setLineFilter] = useState(''); - const [licenseFilter, setLicenseFilter] = useState(''); - const [sortCol, setSortCol] = useState(null); - const [sortDesc, setSortDesc] = useState(false); - const [pageSize, setPageSize] = useState(100); + const [activeTab, setActiveTab] = usePersistentState('descriptions-tab', 'all'); + const [search, setSearch] = usePersistentState('global-search', ''); + const [lineFilter, setLineFilter] = usePersistentState('descriptions-lineFilter', ''); + const [licenseFilter, setLicenseFilter] = usePersistentState('descriptions-licenseFilter', ''); + const [sortCol, setSortCol] = usePersistentState('descriptions-sortCol', null); + const [sortDesc, setSortDesc] = usePersistentState('descriptions-sortDesc', false); + const [pageSize, setPageSize] = usePersistentState('descriptions-pageSize', 100); const [page, setPage] = useState(1); - const [columnFilters, setColumnFilters] = useState>({}); + const [columnFilters, setColumnFilters] = usePersistentState>('descriptions-columnFilters', {}); const [openFilterCol, setOpenFilterCol] = useState(null); const [columnWidths, setColumnWidths] = useState>({ [COLUMNS.ARTICLE_NO]: 110, diff --git a/src/contexts/FilterContext.tsx b/src/contexts/FilterContext.tsx new file mode 100644 index 0000000..6ba166e --- /dev/null +++ b/src/contexts/FilterContext.tsx @@ -0,0 +1,40 @@ +import React, { createContext, useContext, useState, useCallback } from 'react'; + +interface FilterContextType { + states: Record; + setState: (key: string, value: any) => void; +} + +const FilterContext = createContext(undefined); + +export function FilterProvider({ children }: { children: React.ReactNode }) { + const [states, setStates] = useState>({}); + + const setState = useCallback((key: string, value: any) => { + setStates(prev => ({ + ...prev, + [key]: typeof value === 'function' ? value(prev[key]) : value + })); + }, []); + + return ( + + {children} + + ); +} + +export function usePersistentState(key: string, defaultValue: T): [T, (value: T | ((prev: T) => T)) => void] { + const context = useContext(FilterContext); + if (!context) { + throw new Error('usePersistentState must be used within a FilterProvider'); + } + + const state = context.states[key] !== undefined ? context.states[key] : defaultValue; + + const setState = useCallback((value: T | ((prev: T) => T)) => { + context.setState(key, value); + }, [context, key]); + + return [state, setState]; +}