diff --git a/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx b/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx index 96970b1..f92fbd3 100644 --- a/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx +++ b/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx @@ -110,7 +110,7 @@ const createHighlightedContent = (originalValue, displayValue, searchQueries) => const cleanQueries = searchQueries.filter(Boolean); if (cleanQueries.length === 0) return displayValue; - const searchStr = String(originalValue ?? displayValue); + const searchStr = String(displayValue ?? originalValue); const escapedQueries = cleanQueries.map((q) => q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); const regex = new RegExp(`(${escapedQueries.join('|')})`, 'gi'); diff --git a/web/src/components/RealtimeTable/RealtimeTable.jsx b/web/src/components/RealtimeTable/RealtimeTable.jsx index c55aed6..4ca025c 100644 --- a/web/src/components/RealtimeTable/RealtimeTable.jsx +++ b/web/src/components/RealtimeTable/RealtimeTable.jsx @@ -17,7 +17,7 @@ import { SelectExpenseItemModal } from './Modals/SelectExpenseItemModal'; import { SelectVspModal } from './Modals/SelectVspModal'; import { additionExpenseRowTable, additionVspRowTable } from './constants/addingRowConfig'; import { BASE_TABLE_CONFIG, TABLE_ROW_HEIGHT, getTableBodyCellProps, getTablePaperStyles } from './constants/tableConfig'; -import { useRealtimeActions } from './contexts/RealtimeContext'; +import { useRealtimeActions, useVspOptions } from './contexts/RealtimeContext'; import { useColumnSettings } from './hooks/useColumnSettings'; import { useHeaderPortal } from './hooks/useHeaderPortal'; import { useTableScale } from './hooks/useTableScale'; @@ -39,15 +39,15 @@ const ROW_VIRTUALIZER_OPTIONS = { }; const getColumnVirtualizerOptions = ({ table }) => { - const orderedVisibleColumns = [ - ...table.getLeftVisibleLeafColumns(), - ...table.getCenterVisibleLeafColumns(), - ...table.getRightVisibleLeafColumns(), - ]; + const orderedVisibleColumns = table.getVisibleLeafColumns(); const getColumnSize = (index) => orderedVisibleColumns[index]?.getSize() ?? 150; + const scrollPaddingStart = table.getLeftVisibleLeafColumns().reduce((width, column) => width + column.getSize(), 0); + const scrollPaddingEnd = table.getRightVisibleLeafColumns().reduce((width, column) => width + column.getSize(), 0); return { overscan: 10, + scrollPaddingStart, + scrollPaddingEnd, estimateSize: getColumnSize, measureElement: (element) => { if (!element) return 150; @@ -59,6 +59,18 @@ const getColumnVirtualizerOptions = ({ table }) => { const hasVspDropdown = (columns) => columns.some((column) => column.editType === 'vsp_dropdown' || (column.columns?.length && hasVspDropdown(column.columns))); +const normalizeFilterValue = (value) => + String(value ?? '') + .trim() + .toLocaleLowerCase('ru-RU'); + +const doesCellMatchFilter = (rawValue, columnDef, query, vspRegistrationById) => { + if (normalizeFilterValue(rawValue).includes(query)) return true; + if (columnDef?.editType !== 'vsp_dropdown') return false; + + return vspRegistrationById.get(Number(rawValue))?.includes(query) ?? false; +}; + const tableContentStyle = { position: 'relative', width: '100%', @@ -79,6 +91,7 @@ const loadingOverlayStyle = { const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { const { user } = useAuth(); + const vspOptions = useVspOptions(); const userRoleId = user?.role_id; const { data, @@ -87,6 +100,9 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { editingCells: dataEditingCells, } = useRealtimeData(formId, sheetName, direction, formType, year); const [globalFilter, setGlobalFilter] = useState(''); + const [searchMatchIndex, setSearchMatchIndex] = useState(-1); + const [activeSearchMatch, setActiveSearchMatch] = useState(null); + const [pendingSearchDirection, setPendingSearchDirection] = useState(0); const [showColumnFilters, setShowColumnFilters] = useState(false); const [selectedColumnId, setSelectedColumnId] = useState(); const [rowSelection, setRowSelection] = useState({}); @@ -120,9 +136,9 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { const handleGlobalFilterChange = useMemo( () => - debounce((value) => { + debounce((valueOrUpdater) => { startTransition(() => { - setGlobalFilter(value); + setGlobalFilter((currentValue) => (typeof valueOrUpdater === 'function' ? valueOrUpdater(currentValue) : valueOrUpdater)); }); }, 300), [startTransition], @@ -300,6 +316,23 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { [handleUpdateCell], ); + const vspRegistrationById = useMemo( + () => new Map(vspOptions.map((vsp) => [Number(vsp.id), normalizeFilterValue(vsp.registration_number)])), + [vspOptions], + ); + + const globalFilterFn = useCallback( + (row, columnId, filterValue) => { + const query = normalizeFilterValue(filterValue); + if (!query) return true; + + const rawValue = row.getValue(columnId); + const column = row.getAllCells().find((cell) => cell.column.id === columnId)?.column; + return doesCellMatchFilter(rawValue, column?.columnDef, query, vspRegistrationById); + }, + [vspRegistrationById], + ); + const createTableConfig = ({ columns, data, @@ -317,8 +350,11 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { setExpanded, editingCell, contextStartEditing, + globalFilterFn, + activeSearchMatch, }) => ({ ...BASE_TABLE_CONFIG, + globalFilterFn, columns, data, enableRowVirtualization: true, @@ -355,7 +391,21 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { muiTableHeadCellProps: { sx: { boxSizing: 'border-box' }, }, - muiTableBodyCellProps: getTableBodyCellProps, + muiTableBodyCellProps: (props) => { + const cellProps = getTableBodyCellProps(props); + const isActiveMatch = activeSearchMatch?.rowId === props.row.id && activeSearchMatch?.columnId === props.column.id; + + return { + ...cellProps, + sx: { + ...cellProps.sx, + ...(isActiveMatch && { + zIndex: 7, + boxShadow: 'inset 0 0 0 3px #1976d2', + }), + }, + }; + }, muiTableHeadProps: { sx: { display: 'table-header-group', @@ -406,6 +456,8 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { setExpanded, editingCell, contextStartEditing, + globalFilterFn, + activeSearchMatch, }), [ columns, @@ -422,6 +474,8 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { setColumnPinning, editingCell, expanded, + globalFilterFn, + activeSearchMatch, ], ); @@ -431,31 +485,108 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { const handleNavigateToColumn = useCallback( (columnId) => { if (!columnId) return; + console.log(columnId); + console.log(columnId); + console.log(columnId); + console.log(columnId); setSelectedColumnId(columnId); + const pinnedIds = new Set([...(columnPinning?.left || []), ...(columnPinning?.right || [])]); + if (pinnedIds.has(columnId)) return; + + const visibleColumns = table.getVisibleLeafColumns(); + const columnIndex = visibleColumns.findIndex((column) => column.id === columnId); + if (columnIndex < 0) return; + + const columnVirtualizer = columnVirtualizerRef.current; const container = containerRef.current; if (!container) return; - const pinnedIds = new Set(columnPinning?.left || []); - if (pinnedIds.has(columnId)) return; - - const centerColumns = table.getVisibleLeafColumns().filter((column) => !pinnedIds.has(column.id)); - - let offset = 0; - for (const column of centerColumns) { - if (column.id === columnId) break; - offset += column.getSize(); - } + const leftPinnedSize = table.getLeftVisibleLeafColumns().reduce((width, column) => width + column.getSize(), 0); + const calculatedStart = visibleColumns.slice(0, columnIndex).reduce((offset, column) => offset + column.getSize(), 0); + const measurement = columnVirtualizer?.measurementsCache?.[columnIndex]; + const targetStart = measurement?.start ?? calculatedStart; + const targetOffset = Math.max(0, targetStart - leftPinnedSize); container.scrollTo({ - left: Math.max(0, offset - 40), - behavior: 'smooth', + left: targetOffset, }); }, [columnPinning, containerRef, table], ); + const searchMatches = useMemo(() => { + const query = normalizeFilterValue(globalFilter); + if (!query) return []; + + const matches = []; + for (const [rowIndex, row] of table.getRowModel().rows.entries()) { + for (const cell of row.getVisibleCells()) { + if (doesCellMatchFilter(cell.getValue(), cell.column.columnDef, query, vspRegistrationById)) { + matches.push({ rowId: row.id, rowIndex, columnId: cell.column.id }); + } + } + } + + return matches; + }, [data, globalFilter, table, vspRegistrationById, columnVisibility]); + + const navigateToSearchMatch = useCallback( + (direction) => { + if (!searchMatches.length) return; + + setSearchMatchIndex((currentIndex) => { + const startIndex = currentIndex < 0 ? (direction > 0 ? -1 : 0) : currentIndex; + const nextIndex = (startIndex + direction + searchMatches.length) % searchMatches.length; + const match = searchMatches[nextIndex]; + + setActiveSearchMatch(match); + setRowSelection({ [match.rowId]: true }); + console.log(match.columnId); + handleNavigateToColumn(match.columnId); + requestAnimationFrame(() => { + rowVirtualizerRef.current?.scrollToIndex?.(match.rowIndex < 5 ? match.rowIndex : match.rowIndex + 5, { align: 'auto', behavior: 'smooth' }); + }); + + return nextIndex; + }); + }, + [handleNavigateToColumn, searchMatches], + ); + + const handleSearchKeyDown = useCallback( + (event) => { + if (event.key !== 'Enter') return; + + event.preventDefault(); + const direction = event.shiftKey ? -1 : 1; + const inputValue = event.target.value; + + if (normalizeFilterValue(inputValue) !== normalizeFilterValue(globalFilter)) { + handleGlobalFilterChange.clear(); + setGlobalFilter(inputValue); + setPendingSearchDirection(direction); + return; + } + + navigateToSearchMatch(direction); + }, + [globalFilter, handleGlobalFilterChange, navigateToSearchMatch], + ); + + useEffect(() => { + setSearchMatchIndex(-1); + setActiveSearchMatch(null); + }, [globalFilter]); + + useEffect(() => { + if (!pendingSearchDirection) return; + + navigateToSearchMatch(pendingSearchDirection); + setPendingSearchDirection(0); + }, [navigateToSearchMatch, pendingSearchDirection]); + useEffect(() => { if (!dataEditingCells?.line_id) { setEditingCell(null); @@ -623,6 +754,12 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { columnsCurStage={columnsCurStage} onChangeSizeMult={setSizeMult} onGlobalFilterChange={handleGlobalFilterChange} + globalFilter={globalFilter} + onSearchKeyDown={handleSearchKeyDown} + onSearchNext={() => navigateToSearchMatch(1)} + onSearchPrevious={() => navigateToSearchMatch(-1)} + searchMatchCount={searchMatches.length} + searchMatchIndex={searchMatchIndex} sizeMult={sizeMult} onChangeShowColumnFilters={setShowColumnFilters} showColumnFilters={showColumnFilters} diff --git a/web/src/components/RealtimeTable/SettingPanel/SettingPanel.jsx b/web/src/components/RealtimeTable/SettingPanel/SettingPanel.jsx index 092195e..57025c7 100644 --- a/web/src/components/RealtimeTable/SettingPanel/SettingPanel.jsx +++ b/web/src/components/RealtimeTable/SettingPanel/SettingPanel.jsx @@ -32,6 +32,32 @@ const controlSx = { }; const searchSx = { height: '2.5rem' }; +const searchNavigationSx = { + display: 'inline-flex', + alignItems: 'center', + height: '2rem', + border: '1px solid rgba(0, 0, 0, 0.12)', + borderRadius: '0.4rem', + overflow: 'hidden', + backgroundColor: '#fff', +}; +const searchNavigationButtonSx = { + width: '1.65rem', + height: '2rem', + minWidth: '1.65rem', + padding: 0, + borderRadius: 0, + fontSize: '0.95rem', +}; +const searchMatchCounterSx = { + minWidth: '2.7rem', + padding: '0 0.3rem', + fontSize: '0.7rem', + lineHeight: 1, + textAlign: 'center', + whiteSpace: 'nowrap', + color: 'rgba(0, 0, 0, 0.65)', +}; const exportButtonSx = { height: '2.5rem', minHeight: '2.5rem' }; const SettingPanel = ({ @@ -46,6 +72,12 @@ const SettingPanel = ({ columnsCurStage, onChangeSizeMult, onGlobalFilterChange, + globalFilter, + onSearchKeyDown, + onSearchNext, + onSearchPrevious, + searchMatchCount, + searchMatchIndex, sizeMult, onChangeShowColumnFilters, showColumnFilters, @@ -218,7 +250,40 @@ const SettingPanel = ({ - + + + + + + ↑ + + + + + {searchMatchCount ? `${Math.max(searchMatchIndex + 1, 1)}/${searchMatchCount}` : '0/0'} + + + + + ↓ + + + +