From e2760bb39ac24010afaeaff10726e3ea4b2ea713 Mon Sep 17 00:00:00 2001 From: PotapovaA Date: Wed, 19 Aug 2026 17:47:09 +0300 Subject: [PATCH 1/4] fix header --- .../RealtimeTable/RealtimeTable.jsx | 4 ++ .../constants/PROJECT/CURRENT_EXPENSES.js | 24 ++++---- .../RealtimeTable/constants/tableConfig.js | 4 ++ .../components/RealtimeTable/tableColumns.jsx | 60 +++++++++++++------ .../AdminPanel/UsersPage/UserTableForAdd.jsx | 3 + web/src/pages/ProjectPages/SummaryPage.jsx | 10 ++++ 6 files changed, 74 insertions(+), 31 deletions(-) diff --git a/web/src/components/RealtimeTable/RealtimeTable.jsx b/web/src/components/RealtimeTable/RealtimeTable.jsx index 2cebd56..73b574e 100644 --- a/web/src/components/RealtimeTable/RealtimeTable.jsx +++ b/web/src/components/RealtimeTable/RealtimeTable.jsx @@ -35,8 +35,10 @@ import { CreateProgramModal } from './Modals/CreateProgramProjectModal'; import { FormsNoCanAddRow } from './constants/formConfig'; import { DictVspApi } from '../../api/dict-vsp'; +import { useAuth } from '../../app/context/AuthProvider'; const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { + const { user } = useAuth(); const { data, setData, columnsCurStage, isLoading: isTableLoading, editingCells: dataEditingCells } = useRealtimeData(formId, sheetName, direction, formType, year); const [globalFilter, setGlobalFilter] = useState(''); const [showColumnFilters, setShowColumnFilters] = useState(false); @@ -215,6 +217,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { EditCell, columnsConfig, columnsCurStage, + user, onCellUpdate: handleUpdateCell, onCellNumberClick: handleClickRowCell, onCellUpdateError: handleCellUpdateError, @@ -227,6 +230,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { }, [ columnsConfig, columnsCurStage, + user, handleCellUpdateError, handleClickRowCell, handleUpdateCell, diff --git a/web/src/components/RealtimeTable/constants/PROJECT/CURRENT_EXPENSES.js b/web/src/components/RealtimeTable/constants/PROJECT/CURRENT_EXPENSES.js index 4efa3aa..247361b 100644 --- a/web/src/components/RealtimeTable/constants/PROJECT/CURRENT_EXPENSES.js +++ b/web/src/components/RealtimeTable/constants/PROJECT/CURRENT_EXPENSES.js @@ -279,7 +279,7 @@ export const config = { accessorKey: 'otchet_ob_ispolnenii_tekuschikh_raskhodov_v_i_kvartale_korrektirovka_limita', columns: [ { - header: 'По статьям сметы *', + header: 'По статьям сметы*', accessorKey: 'data.q1.adj_by_items', columnLetter: 'E', size: 180, @@ -292,7 +292,7 @@ export const config = { }, }, { - header: 'Увеличение сметы **', + header: 'Увеличение сметы**', accessorKey: 'data.q1.adj_increase', columnLetter: 'F', size: 200, @@ -368,7 +368,7 @@ export const config = { }, }, { - header: 'За', + header: 'За март', accessorKey: 'data.q1.m3', columnLetter: 'J', size: 150, @@ -462,7 +462,7 @@ export const config = { accessorKey: 'otchet_ob_ispolnenii_tekuschikh_raskhodov_v_ii_kvartale_korrektirovka_limita', columns: [ { - header: 'По статьям сметы *', + header: 'По статьям сметы*', accessorKey: 'data.q2.adj_by_items', columnLetter: 'O', size: 180, @@ -475,7 +475,7 @@ export const config = { }, }, { - header: 'Увеличение сметы **', + header: 'Увеличение сметы**', accessorKey: 'data.q2.adj_increase', columnLetter: 'P', size: 200, @@ -551,7 +551,7 @@ export const config = { }, }, { - header: 'За', + header: 'За июнь', accessorKey: 'data.q2.m3', columnLetter: 'T', size: 150, @@ -645,7 +645,7 @@ export const config = { accessorKey: 'otchet_ob_ispolnenii_tekuschikh_raskhodov_v_iii_kvartale_korrektirovka_limita', columns: [ { - header: 'По статьям сметы *', + header: 'По статьям сметы*', accessorKey: 'data.q3.adj_by_items', columnLetter: 'Y', size: 180, @@ -658,7 +658,7 @@ export const config = { }, }, { - header: 'Увеличение сметы **', + header: 'Увеличение сметы**', accessorKey: 'data.q3.adj_increase', columnLetter: 'Z', size: 200, @@ -734,7 +734,7 @@ export const config = { }, }, { - header: 'За', + header: 'За сентябрь', accessorKey: 'data.q3.m3', columnLetter: 'AD', size: 150, @@ -828,7 +828,7 @@ export const config = { accessorKey: 'otchet_ob_ispolnenii_tekuschikh_raskhodov_v_iv_kvartale_korrektirovka_limita', columns: [ { - header: 'По статьям сметы *', + header: 'По статьям сметы*', accessorKey: 'data.q4.adj_by_items', columnLetter: 'AI', size: 180, @@ -841,7 +841,7 @@ export const config = { }, }, { - header: 'Увеличение сметы **', + header: 'Увеличение сметы**', accessorKey: 'data.q4.adj_increase', columnLetter: 'AJ', size: 200, @@ -917,7 +917,7 @@ export const config = { }, }, { - header: 'За', + header: 'За декабрь', accessorKey: 'data.q4.m3', columnLetter: 'AN', size: 150, diff --git a/web/src/components/RealtimeTable/constants/tableConfig.js b/web/src/components/RealtimeTable/constants/tableConfig.js index 41fd6a6..49cfedc 100644 --- a/web/src/components/RealtimeTable/constants/tableConfig.js +++ b/web/src/components/RealtimeTable/constants/tableConfig.js @@ -28,6 +28,10 @@ export const BASE_TABLE_CONFIG = { defaultColumn: { filterFn: 'contains' }, columnResizeMode: 'onChange', localization: { + expand: 'Раскрыть', + expandAll: 'Раскрыть все', + collapse: 'Свернуть', + collapseAll: 'Свернуть все', noRecordsToDisplay: Нет данных для отображения, }, }; diff --git a/web/src/components/RealtimeTable/tableColumns.jsx b/web/src/components/RealtimeTable/tableColumns.jsx index 5c3c912..fb39e49 100644 --- a/web/src/components/RealtimeTable/tableColumns.jsx +++ b/web/src/components/RealtimeTable/tableColumns.jsx @@ -5,7 +5,6 @@ import { useRealtime } from './contexts/RealtimeContext'; import { sectionCodeColor } from './constants/columnConfig'; import { blueColumn, greenColumn } from './constants/columnColors'; -import { useAuth } from '../../app/context/AuthProvider'; import { ROLES_NAME_ID } from '../../constants/constants'; import VspDropdownEditCell from './Cell/EditCell/VspDropdownEditCell'; @@ -94,18 +93,38 @@ const EditCellPortal = React.memo(({ }); function getColorCell(colors, column, row) { - if (row.original.data.section_code) { - const section_code = row.original.data.section_code[0]; - return (sectionCodeColor[section_code] || blueColumn)[row.original?.row_type || row.row_type]; + const rowType = row?.original?.row_type || row?.row_type; + const sectionCodeValue = + row?.original?.data?.section_code ?? row?.original?.data?.header?.section_code; + + if (sectionCodeValue !== null && sectionCodeValue !== undefined && sectionCodeValue !== '') { + const sectionCode = String(sectionCodeValue)[0]; + return (sectionCodeColor[sectionCode] || blueColumn)[rowType]; } - return colors[column.id]?.color_type?.[row.original?.row_type || row.row_type]; + + return colors[column.id]?.color_type?.[rowType]; } +const UPPERCASE_MONTH_PATTERN = + /ЯНВАРЬ|ФЕВРАЛЬ|МАРТ|АПРЕЛЬ|МАЙ|ИЮНЬ|ИЮЛЬ|АВГУСТ|СЕНТЯБРЬ|ОКТЯБРЬ|НОЯБРЬ|ДЕКАБРЬ/g; + +const normalizeMonthHeader = (header) => { + if (typeof header !== 'string') return header; + + return header.replace(UPPERCASE_MONTH_PATTERN, (month, offset) => { + const normalizedMonth = month.toLowerCase(); + if (offset !== 0) return normalizedMonth; + + return normalizedMonth[0].toUpperCase() + normalizedMonth.slice(1); + }); +}; + export const getTableColumns = ({ Cell, EditCell, columnsConfig, columnsCurStage, + user, onCellUpdateError, onCellNumberClick, isCellInvalid, @@ -113,8 +132,6 @@ export const getTableColumns = ({ const columnColors = { ...columnsConfig.colors }; const columns = structuredClone(columnsConfig.columns); - const { user } = useAuth(); - const cellPropsCache = new WeakMap(); const editPropsCache = new WeakMap(); @@ -130,6 +147,9 @@ export const getTableColumns = ({ const key = `${row.id}_${column.id}`; const value = row.getValue(column.id); const isInvalid = isCellInvalid?.(column.id, value, row.original) || false; + const isEditableCell = isEditable(row, column); + const backgroundColor = getColorCell(columnColors, column, row); + const isUpdating = table.options.meta?.updatingCells?.[key]; if (!cellPropsCache.has(row)) { cellPropsCache.set(row, {}); @@ -141,12 +161,13 @@ export const getTableColumns = ({ const currentGlobalFilter = table.getState().globalFilter; const currentColumnFilter = table.getState().columnFilters?.find(f => f.id === column.id)?.value; - if (cached.globalFilter === currentGlobalFilter && - cached.columnFilter === currentColumnFilter && - cached.value === value && - cached.isInvalid === isInvalid) { - return cached; - } + if (cached.globalFilter === currentGlobalFilter && + cached.columnFilter === currentColumnFilter && + cached.value === value && + cached.isInvalid === isInvalid && + cached.backgroundColor === backgroundColor) { + return cached; + } } const globalFilter = table.getState().globalFilter; @@ -155,12 +176,11 @@ export const getTableColumns = ({ const props = { globalFilter, columnFilter, - isEditable: isEditable(row, column), - backgroundColor: getColorCell(columnColors, column, row), - isUpdating: table.options.meta?.updatingCells?.[`${row.id}_${column.id}`], + isEditable: isEditableCell, + backgroundColor, isInvalid, value, - _hash: `${globalFilter}_${columnFilter}_${row.id}_${column.id}_${value}_${isInvalid}`, + _hash: `${globalFilter}_${columnFilter}_${key}_${value}_${isInvalid}_${backgroundColor}`, }; rowCache[key] = props; @@ -190,6 +210,8 @@ export const getTableColumns = ({ // Оптимизированная функция processColumns const processColumns = (columns) => { for (const col of columns) { + col.header = normalizeMonthHeader(col.header); + col.Cell = ({ cell, table, column, row }) => { const { vspOptions } = useRealtime(); const props = getCachedCellProps(row, column, table); @@ -236,7 +258,7 @@ export const getTableColumns = ({ const rowNumber = { accessorKey: 'sort_order', - header: '#', + header: '№', size: 50, enableEditing: false, Cell: ({ cell, row }) => { @@ -248,4 +270,4 @@ export const getTableColumns = ({ }; return [rowNumber, ...columns]; -}; \ No newline at end of file +}; diff --git a/web/src/pages/AdminPanel/UsersPage/UserTableForAdd.jsx b/web/src/pages/AdminPanel/UsersPage/UserTableForAdd.jsx index 838858f..a0b5716 100644 --- a/web/src/pages/AdminPanel/UsersPage/UserTableForAdd.jsx +++ b/web/src/pages/AdminPanel/UsersPage/UserTableForAdd.jsx @@ -281,6 +281,9 @@ const UserTableForAdd = ({ usersEmail = [], sspOptions = [], onDeleteUser, onCha maxHeight: '18.38rem', }, }, + localization: { + noRecordsToDisplay: 'Нет данных для отображения', + }, enableRowNumbers: false, muiToolbarAlertBannerProps: false, }); diff --git a/web/src/pages/ProjectPages/SummaryPage.jsx b/web/src/pages/ProjectPages/SummaryPage.jsx index 7802348..4e63921 100644 --- a/web/src/pages/ProjectPages/SummaryPage.jsx +++ b/web/src/pages/ProjectPages/SummaryPage.jsx @@ -472,6 +472,13 @@ const SummaryPage = () => { columns: firstColumns, data: filteredData, ...projectsTableOptions, + localization: { + expand: 'Раскрыть', + expandAll: 'Раскрыть все', + collapse: 'Свернуть', + collapseAll: 'Свернуть все', + noRecordsToDisplay: 'Нет данных для отображения', + }, initialState: { columnPinning: { right: ['actions'], left: ['mrt-row-expand', 'project'] }, }, @@ -482,6 +489,9 @@ const SummaryPage = () => { columns: secondColumns, data: summaryData, ...summaryProjectTableOptions, + localization: { + noRecordsToDisplay: 'Нет данных для отображения', + }, initialState: { columnPinning: { left: ['data.header.name'] }, }, -- 2.47.2 From 336e4123e5905192ec56eee370cfaa62699560be Mon Sep 17 00:00:00 2001 From: PotapovaA Date: Wed, 19 Aug 2026 18:24:59 +0300 Subject: [PATCH 2/4] fix --- .../ColumnSelectionOverlay.jsx | 6 +- .../RealtimeTable/RealtimeTable.jsx | 297 +++++++++--------- .../SettingPanel/SettingPanel.jsx | 82 +++-- .../RealtimeTable/TableHead/TableHead.jsx | 201 ++++-------- 4 files changed, 259 insertions(+), 327 deletions(-) diff --git a/web/src/components/RealtimeTable/ColumnSelectionOverlay/ColumnSelectionOverlay.jsx b/web/src/components/RealtimeTable/ColumnSelectionOverlay/ColumnSelectionOverlay.jsx index 2470c03..1b27196 100644 --- a/web/src/components/RealtimeTable/ColumnSelectionOverlay/ColumnSelectionOverlay.jsx +++ b/web/src/components/RealtimeTable/ColumnSelectionOverlay/ColumnSelectionOverlay.jsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { memo, useEffect, useState } from 'react'; import { createPortal } from 'react-dom'; const toLocalCoord = (value, scale) => value / scale; @@ -54,7 +54,7 @@ const getOverlayRect = (container, overlayParent, selectedColumnId, columnPinnin return { top, left, width, height }; }; -export const ColumnSelectionOverlay = ({ selectedColumnId, containerRef, scale = 1, columnSizing, columnPinning, showColumnFilters }) => { +const ColumnSelectionOverlayComponent = ({ selectedColumnId, containerRef, scale = 1, columnSizing, columnPinning, showColumnFilters }) => { const [rect, setRect] = useState(null); const [portalTarget, setPortalTarget] = useState(null); @@ -135,3 +135,5 @@ export const ColumnSelectionOverlay = ({ selectedColumnId, containerRef, scale = portalTarget, ); }; + +export const ColumnSelectionOverlay = memo(ColumnSelectionOverlayComponent); diff --git a/web/src/components/RealtimeTable/RealtimeTable.jsx b/web/src/components/RealtimeTable/RealtimeTable.jsx index 73b574e..5e3ed64 100644 --- a/web/src/components/RealtimeTable/RealtimeTable.jsx +++ b/web/src/components/RealtimeTable/RealtimeTable.jsx @@ -29,7 +29,6 @@ import { additionExpenseRowTable, additionVspRowTable } from './constants/adding import { SelectVspModal } from './Modals/SelectVspModal'; import { SelectExpenseItemModal } from './Modals/SelectExpenseItemModal'; import { getRowId } from './utils/rowUtils'; -import { FORM_TYPE_OPTIONS } from '../../constants/constants'; import { debounce } from '@mui/material'; import { CreateProgramModal } from './Modals/CreateProgramProjectModal'; @@ -37,15 +36,69 @@ import { FormsNoCanAddRow } from './constants/formConfig'; import { DictVspApi } from '../../api/dict-vsp'; import { useAuth } from '../../app/context/AuthProvider'; +const CONFIG_CACHE = new Map(); + +const ROW_VIRTUALIZER_OPTIONS = { + overscan: 5, + scrollPaddingStart: 0, + scrollPaddingEnd: 0, + estimateSize: () => TABLE_ROW_HEIGHT, + measureElement: (element) => element?.offsetHeight || TABLE_ROW_HEIGHT, +}; + +const getColumnVirtualizerOptions = ({ table }) => { + const orderedVisibleColumns = [ + ...table.getLeftVisibleLeafColumns(), + ...table.getCenterVisibleLeafColumns(), + ...table.getRightVisibleLeafColumns(), + ]; + const getColumnSize = (index) => orderedVisibleColumns[index]?.getSize() ?? 150; + + return { + overscan: 10, + estimateSize: getColumnSize, + measureElement: (element) => { + if (!element) return 150; + return getColumnSize(Number(element.getAttribute('data-index'))); + }, + }; +}; + +const hasVspDropdown = (columns) => + columns.some( + (column) => + column.editType === 'vsp_dropdown' || + (column.columns?.length && hasVspDropdown(column.columns)), + ); + +const tableContentStyle = { + position: 'relative', + width: '100%', + flex: 1, + minHeight: 0, + height: '100%', +}; + +const loadingOverlayStyle = { + position: 'absolute', + inset: 0, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + background: 'rgba(255, 255, 255, 0.6)', + zIndex: 2, +}; + const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { const { user } = useAuth(); - const { data, setData, columnsCurStage, isLoading: isTableLoading, editingCells: dataEditingCells } = useRealtimeData(formId, sheetName, direction, formType, year); + const { data, columnsCurStage, isLoading: isTableLoading, editingCells: dataEditingCells } = useRealtimeData(formId, sheetName, direction, formType, year); const [globalFilter, setGlobalFilter] = useState(''); const [showColumnFilters, setShowColumnFilters] = useState(false); const [selectedColumnId, setSelectedColumnId] = useState(); const [rowSelection, setRowSelection] = useState({}); - const [isLoadingData, setIsLoadingData] = useState(false); - const [isPending, startTransition] = useTransition(); + const rowSelectionRef = useRef(rowSelection); + rowSelectionRef.current = rowSelection; + const [, startTransition] = useTransition(); const [editingCell, setEditingCell] = useState(null); const [columnsConfig, setColumnsConfig] = useState(null); const { errors: validationErrors, isCellInvalid } = useValidationRules( @@ -60,13 +113,12 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { const [isOpenModalSelectVsp, setIsOpenModalSelectVsp] = useState(false); const [isOpenModalSelectExpenseItem, setIsOpenModalSelectExpenseItem] = useState(false); - const [isOpenModalCreateProgram, setIsOpenModalCreateProgram] = useState(false); - const [isOpenModalCreateProject, setIsOpenModalCreateProject] = useState(false); - const [isProgramCreate, setIsProgramCreate] = useState(false) + const [createModalType, setCreateModalType] = useState(null); - const [isFormNoCanAddRow, setIsFormNoCanAddRow] = useState(() => { - return (FormsNoCanAddRow[formType].includes(sheetName)); - }) + const isFormNoCanAddRow = useMemo( + () => FormsNoCanAddRow[formType]?.includes(sheetName) ?? false, + [formType, sheetName], + ); const isVspAdditionRow = useMemo(() => { if (!formType || !sheetName) return false; @@ -80,21 +132,18 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { return additionExpenseRowTable[formType].includes(sheetName); }, [formType, sheetName]); - const handleGlobalFilterChange = useCallback( - debounce((value) => { + const handleGlobalFilterChange = useMemo( + () => debounce((value) => { startTransition(() => { setGlobalFilter(value); }); }, 300), - [] + [startTransition], ); + useEffect(() => () => handleGlobalFilterChange.clear(), [handleGlobalFilterChange]); + const { - isConnected, - isAuthenticated, - subscribeToErrors, - subscribeToAuthSuccess, - error: wsError, updateCell: contextUpdateCell, addRow: contextAddRow, deleteRow: contextDeleteRow, @@ -118,6 +167,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { const { headerPortalRef, containerRef } = useHeaderPortal(); const rowVirtualizerRef = useRef(null); + const columnVirtualizerRef = useRef(null); const { sizeMult, setSizeMult, tableScaleStyle, tableWrapperStyle } = useTableScale(); @@ -126,21 +176,19 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { rowVirtualizerRef.current?.measure?.(); }, [sizeMult]); - const configCache = useRef(new Map()); - useEffect(() => { if (!formType || !sheetName) return; const cacheKey = `${formType}_${sheetName}`; - if (configCache.current.has(cacheKey)) { - setColumnsConfig(configCache.current.get(cacheKey)); + if (CONFIG_CACHE.has(cacheKey)) { + setColumnsConfig(CONFIG_CACHE.get(cacheKey)); return; } const loadConfig = async () => { try { const { config } = await import(`./constants/${formType}/${sheetName}.js`); - configCache.current.set(cacheKey, config.config); + CONFIG_CACHE.set(cacheKey, config.config); setColumnsConfig(config.config); } catch (error) { console.error(`Failed to load config for ${formType}/${sheetName}:`, error); @@ -154,14 +202,6 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { useEffect(() => { if (!formId || !columnsConfig?.columns) return; - const hasVspDropdown = (cols) => { - for (const col of cols) { - if (col.editType === 'vsp_dropdown') return true; - if (col.columns && hasVspDropdown(col.columns)) return true; - } - return false; - }; - if (!hasVspDropdown(columnsConfig.columns)) return; DictVspApi.getDropdownVsp({ form_id: formId }).then(data => { @@ -237,6 +277,28 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { isCellInvalid, ]); + // MRT запоминает индексы pinned-колонок до завершения асинхронной + // загрузки конфигурации. Новая ссылка на columnPinning заставляет + // виртуализатор пересчитать эти индексы уже с полным набором колонок. + useEffect(() => { + if (!columnsConfig?.columns?.length) return; + + setColumnPinning((currentPinning) => ({ + left: [...(currentPinning.left || [])], + right: [...(currentPinning.right || [])], + })); + }, [columnsConfig, setColumnPinning]); + + useEffect(() => { + if (!columns.length) return; + + const animationFrameId = requestAnimationFrame(() => { + columnVirtualizerRef.current?.measure?.(); + }); + + return () => cancelAnimationFrame(animationFrameId); + }, [columns, columnPinning, columnSizing, columnVisibility]); + const selectedColumnIdRef = useRef(selectedColumnId); useEffect(() => { @@ -248,14 +310,6 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { getSelectedColumnId: () => selectedColumnIdRef.current, }), [handleUpdateCell]); - const ROW_VIRTUALIZER_OPTIONS = { - overscan: 5, - scrollPaddingStart: 0, - scrollPaddingEnd: 0, - estimateSize: () => TABLE_ROW_HEIGHT, - measureElement: (el) => el?.offsetHeight || TABLE_ROW_HEIGHT, - }; - const createTableConfig = ({ columns, data, @@ -280,27 +334,14 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { enableRowVirtualization: true, enableColumnVirtualization: true, rowVirtualizerInstanceRef: rowVirtualizerRef, + columnVirtualizerInstanceRef: columnVirtualizerRef, rowVirtualizerOptions: ROW_VIRTUALIZER_OPTIONS, onEditingCellChange: (cell) => { if (cell && !editingCell) { contextStartEditing?.(cell.row, cell.column); } }, - columnVirtualizerOptions: ({ table }) => ({ - overscan: 10, - measureElement: (el) => { - if (!el) return 150; - const index = Number(el?.getAttribute?.('data-index')); - const isPinned = Boolean(el?.getAttribute?.('data-pinned')); - if (isPinned) { - const colId = table.getState().columnPinning.left[index]; - const column = table.getColumn(colId); - return column?.getSize() ?? 150; - } - const allCols = [...table.getLeftVisibleLeafColumns(), ...table.getCenterVisibleLeafColumns()] - return allCols[index]?.getSize() ?? 150; - }, - }), + columnVirtualizerOptions: getColumnVirtualizerOptions, enableRowSelection: true, onRowSelectionChange: setRowSelection, onColumnSizingChange: setColumnSizing, @@ -391,6 +432,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { ]); const table = useMaterialReactTable(tableConfig); + const isLoadingData = columns.length > 0; const handleNavigateToColumn = useCallback((columnId) => { if (!columnId) return; @@ -426,7 +468,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { } if (editingCell) return; - requestAnimationFrame(() => { + const animationFrameId = requestAnimationFrame(() => { try { const row = table.getRow(dataEditingCells.line_id); const cell = row?.getVisibleCells().find( @@ -437,88 +479,72 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { console.error(error); } }); + + return () => cancelAnimationFrame(animationFrameId); }, [dataEditingCells, editingCell, table]); - const [isFirstLoad, setIsFirstLoad] = useState(true); - - useEffect(() => { - if (isFirstLoad && table && data && columns.length !== 0) { - setIsFirstLoad(false); - setIsLoadingData(true); - } - }, [data, columns, table, isFirstLoad]); + const getSelectedRow = useCallback( + () => table.getRowModel().rows.find((row) => rowSelectionRef.current[row.id]), + [table], + ); const handleAddRow = useCallback(() => { - const allRows = table.getRowModel().rows; - - const selectedRows = allRows.filter(row => rowSelection[row.id]); - if (selectedRows.length === 0) { + const row = getSelectedRow(); + if (!row) { toast.error('Выделите строку для вставки'); return; } - const row = selectedRows[0]; - const expense_item_id = row.original.data.header.expense_item_id; - contextAddRow({ expense_item_id: expense_item_id }) - }, [rowSelection, table]) + contextAddRow({ expense_item_id: row.original.data.header.expense_item_id }); + }, [contextAddRow, getSelectedRow]); const handleAddVspRow = useCallback((vsp_id) => { - contextAddRow({ vsp_id: vsp_id }); - }, []); + contextAddRow({ vsp_id }); + }, [contextAddRow]); const handleAddExpenseItemRow = useCallback((expense_item) => { - const allRows = table.getRowModel().rows; - const selectedRows = allRows.filter(row => rowSelection[row.id]); - const row = selectedRows[0]; + const row = getSelectedRow(); + if (!row) return; contextAddRow({ expense_item_id: expense_item.id, project_id: row.original.data.header.project_id }); - }, [rowSelection]); + }, [contextAddRow, getSelectedRow]); const handleAddProgramRow = useCallback((name) => { - contextAddProgram({ name: name }); - }, []); + contextAddProgram({ name }); + }, [contextAddProgram]); const handleAddProjectRow = useCallback((name) => { - const allRows = table.getRowModel().rows; - const selectedRows = allRows.filter(row => rowSelection[row.id]); - const row = selectedRows[0]; - contextAddProject({ name: name, program_id: row.original.data.header.program_id }); - }, [rowSelection]); + const row = getSelectedRow(); + if (!row) return; + contextAddProject({ name, program_id: row.original.data.header.program_id }); + }, [contextAddProject, getSelectedRow]); const handleOpenModalSelectVsp = useCallback(() => { setIsOpenModalSelectVsp(true); - }, []) + }, []); const handleOpenModalSelectExpenseItem = useCallback(() => { - const allRows = table.getRowModel().rows; - - const selectedRows = allRows.filter(row => rowSelection[row.id]); - if (selectedRows.length === 0) { + const row = getSelectedRow(); + if (!row) { toast.error('Выделите строку для вставки'); return; } - const row = selectedRows[0]; if (row.original.row_type !== 'ITEM') { toast.error('Выделите строку проекта'); return; } setIsOpenModalSelectExpenseItem(true); - }, [rowSelection]) + }, [getSelectedRow]); const handleDeleteRow = useCallback(() => { - const allRows = table.getRowModel().rows; - - const selectedRows = allRows.filter(row => rowSelection[row.id]); - if (selectedRows.length === 0) { + const row = getSelectedRow(); + if (!row) { toast.error('Выделите строку для удаления'); return; } - const row = selectedRows[0]; - const rowId = getRowId(row); - contextDeleteRow(rowId); + contextDeleteRow(getRowId(row)); setRowSelection({}); - }, [rowSelection, table]) + }, [contextDeleteRow, getSelectedRow]); const addRow = useCallback(() => { - if (isVspAdditionRow) { return handleOpenModalSelectVsp(); } @@ -529,35 +555,30 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { }, [isVspAdditionRow, isAdditionExpenseItem, handleOpenModalSelectVsp, handleAddRow, handleOpenModalSelectExpenseItem]); const addProgram = useCallback(() => { - setIsProgramCreate(true); - setIsOpenModalCreateProgram(true); - }, []) + setCreateModalType('program'); + }, []); const addProject = useCallback(() => { - setIsProgramCreate(false); - const allRows = table.getRowModel().rows; - - const selectedRows = allRows.filter(row => rowSelection[row.id]); - if (selectedRows.length === 0) { + const row = getSelectedRow(); + if (!row) { toast.error('Выделите строку для вставки'); return; } - const row = selectedRows[0]; if (row.original.row_type !== 'GROUP') { toast.error('Выделите строку программы'); return; } - setIsOpenModalCreateProgram(true); - }, [setIsOpenModalCreateProgram, rowSelection]) + setCreateModalType('project'); + }, [getSelectedRow]); + + const closeSelectVspModal = useCallback(() => setIsOpenModalSelectVsp(false), []); + const closeSelectExpenseItemModal = useCallback(() => setIsOpenModalSelectExpenseItem(false), []); + const closeCreateModal = useCallback(() => setCreateModalType(null), []); useEffect(() => { return () => { - setData([]); - setRowSelection({}); - setEditingCell(null); - if (rowVirtualizerRef.current) { - rowVirtualizerRef.current = null; - } + rowVirtualizerRef.current = null; + columnVirtualizerRef.current = null; }; }, []); @@ -567,7 +588,6 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { if (newState) { const allRows = table.getRowModel().flatRows; - console.log(allRows); const newExpanded = {}; for (const row of allRows) { @@ -603,6 +623,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { onGlobalFilterChange={handleGlobalFilterChange} sizeMult={sizeMult} onChangeShowColumnFilters={setShowColumnFilters} + showColumnFilters={showColumnFilters} onAddRow={addRow} onAddProgram={addProgram} onAddProject={addProject} @@ -622,15 +643,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
-
+
{ /> {isTableLoading && ( -
+
)} @@ -662,7 +665,11 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { createPortal( theme.zIndex.modal - 1 }} + columns={columns} + columnPinning={columnPinning} + columnSizing={columnSizing} + columnVisibility={columnVisibility} + showColumnFilters={showColumnFilters} selectedColumnId={selectedColumnId} onColumnSelect={handleColumnSelect} onChangeWidth={handleSetColumnWidth} @@ -675,24 +682,24 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
setIsOpenModalSelectVsp(false)} + onClose={closeSelectVspModal} formId={formId} onSelect={handleAddVspRow} title="Выбор ВСП" /> setIsOpenModalSelectExpenseItem(false)} + onClose={closeSelectExpenseItemModal} formId={formId} onSelect={handleAddExpenseItemRow} title="Добавление строки" sheet={sheetName} /> setIsOpenModalCreateProgram(false)} - title={isProgramCreate ? "Создание программы" : "Создание проекта"} - onCreate={isProgramCreate ? handleAddProgramRow : handleAddProjectRow} + isOpen={createModalType !== null} + onClose={closeCreateModal} + title={createModalType === 'program' ? 'Создание программы' : 'Создание проекта'} + onCreate={createModalType === 'program' ? handleAddProgramRow : handleAddProjectRow} /> ); diff --git a/web/src/components/RealtimeTable/SettingPanel/SettingPanel.jsx b/web/src/components/RealtimeTable/SettingPanel/SettingPanel.jsx index fde2759..1851c34 100644 --- a/web/src/components/RealtimeTable/SettingPanel/SettingPanel.jsx +++ b/web/src/components/RealtimeTable/SettingPanel/SettingPanel.jsx @@ -1,5 +1,5 @@ import { Divider, IconButton, Stack, Tooltip } from '@mui/material'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { memo, useCallback, useMemo, useState } from 'react'; import { FORM_TYPE_OPTIONS } from '../../../constants/constants'; import { exportSheet, exportSheetProject } from '../../../utils/exportFile'; import { ExportDefaultButton } from '../../common/Buttons/ButtonsActions'; @@ -31,6 +31,9 @@ const controlSx = { flex: '0 0 2.5rem', }; +const searchSx = { height: '2.5rem' }; +const exportButtonSx = { height: '2.5rem', minHeight: '2.5rem' }; + const SettingPanel = ({ selectedColumnId, columnPinning, @@ -45,6 +48,7 @@ const SettingPanel = ({ onGlobalFilterChange, sizeMult, onChangeShowColumnFilters, + showColumnFilters, onAddRow, onAddProgram, onAddProject, @@ -61,49 +65,43 @@ const SettingPanel = ({ showOnlyDepth2, onToggleDepthVisibility, }) => { - const [isPinned, setIsPinned] = useState(false); const [isExporting, setIsExporting] = useState(false); - const [selectedColumnIds, setSelectedColumnIds] = useState(); - const [showColumnFilters, setShowColumnFilters] = useState(false); - - useEffect(() => { - if (!selectedColumnId) { - setIsPinned(false); - return; - } - const pinningColumn = columnPinning?.left || table.getState().columnPinning.left || []; - const pinned = pinningColumn.includes(selectedColumnId); - setIsPinned(pinned); - }, [selectedColumnId, columnPinning, table]); - - useEffect(() => { - setSelectedColumnIds(Object.keys(columnVisibility).filter((key) => !columnVisibility[key])); - }, [columnVisibility]); + const isPinned = useMemo( + () => Boolean(selectedColumnId && columnPinning?.left?.includes(selectedColumnId)), + [columnPinning, selectedColumnId], + ); + const selectedColumnIds = useMemo( + () => Object.keys(columnVisibility).filter((key) => !columnVisibility[key]), + [columnVisibility], + ); //TO DO: сделать const { addColorForCells } = {}; - const handleChangeShowColumnFilters = () => { - const show = table.getState().showColumnFilters; - setShowColumnFilters(!show); - onChangeShowColumnFilters(!show); - }; + const handleChangeShowColumnFilters = useCallback(() => { + onChangeShowColumnFilters(!showColumnFilters); + }, [onChangeShowColumnFilters, showColumnFilters]); - const handleExport = async () => { + const handleExport = useCallback(async () => { if (isExporting) return; - if (formType === 'PROJECT') { - if (!formId || !sheetName || !year) return; - setIsExporting(true); - await exportSheetProject(formId, sheetName, year); - setIsExporting(false); - return; - } + if (!formId || !sheetName || (formType === 'PROJECT' && !year)) return; - if (!formId || !sheetName) return; setIsExporting(true); - await exportSheet(formId, sheetName, direction); - setIsExporting(false); - }; + try { + if (formType === 'PROJECT') { + await exportSheetProject(formId, sheetName, year); + } else { + await exportSheet(formId, sheetName, direction); + } + } finally { + setIsExporting(false); + } + }, [direction, formId, formType, isExporting, sheetName, year]); + + const handleTogglePin = useCallback(() => { + if (!selectedColumnId) return; + isPinned ? onUnpinColumn?.(selectedColumnId) : onPinColumn?.(selectedColumnId); + }, [isPinned, onPinColumn, onUnpinColumn, selectedColumnId]); const leafColumns = useMemo(() => collectLeafColumns({ columns }), [columns]); const stageColumnIds = useMemo( () => new Set([...stageColumnsHiddenFromPicker, ...(columnsCurStage || [])]), @@ -140,13 +138,7 @@ const SettingPanel = ({ data-active={isPinned} disabled={!selectedColumnId} onMouseDown={(e) => e.stopPropagation()} - onClick={() => { - if (!selectedColumnId) { - console.warn('[ColumnPin] клик Pin: selectedColumnId пустой'); - return; - } - isPinned ? onUnpinColumn?.(selectedColumnId) : onPinColumn?.(selectedColumnId); - }}> + onClick={handleTogglePin}> @@ -223,7 +215,7 @@ const SettingPanel = ({ - + @@ -253,4 +245,4 @@ const SettingPanel = ({ ); }; -export default SettingPanel; +export default memo(SettingPanel); diff --git a/web/src/components/RealtimeTable/TableHead/TableHead.jsx b/web/src/components/RealtimeTable/TableHead/TableHead.jsx index 189702c..9910a5e 100644 --- a/web/src/components/RealtimeTable/TableHead/TableHead.jsx +++ b/web/src/components/RealtimeTable/TableHead/TableHead.jsx @@ -1,75 +1,22 @@ import React, { useMemo } from 'react'; -import { columnPinningDefault } from '../constants/columnConfig'; import ColumnResizer from './ColumnResizer'; -const getPinnedColumnIds = (table) => (table.getState().columnPinning.left || []).filter(Boolean); - const getSortedColumns = (table) => { - const allLeafColumns = table.getAllLeafColumns(); - const pinnedColumns = getPinnedColumnIds(table); - - return [...allLeafColumns].sort((a, b) => { - const aIsPinned = pinnedColumns.includes(a.id); - const bIsPinned = pinnedColumns.includes(b.id); - - if (aIsPinned && bIsPinned) { - return pinnedColumns.indexOf(a.id) - pinnedColumns.indexOf(b.id); - } - if (aIsPinned) return -1; - if (bIsPinned) return 1; - return 0; - }); + return [ + ...table.getLeftVisibleLeafColumns(), + ...table.getCenterVisibleLeafColumns(), + ...table.getRightVisibleLeafColumns(), + ]; }; -const getColumnId = (header) => { - //в header.id содержаться колонки по типу depth_idColParent_idChild, а в pinningColumn только idCol, которые закреплены - //header.id.split('_').at(-1) - получение самой нижней колонки - let columnId = header?.id?.match(/data\..*$/)?.[0] || header?.id || header; - columnPinningDefault.left.forEach((colPinned) => { - if (columnId.includes(colPinned)) { - columnId = colPinned; - } - }); - return columnId; -}; +const getColumnPinnedStyles = (column) => { + const pinningPosition = column.getIsPinned(); -// Ширина закреплённых дочерних колонок группового заголовка -const getPinnedColumnsWidth = (header, pinningColumn) => { - let width = 0; - for (const leaf of header.getLeafHeaders()) { - if (pinningColumn.includes(leaf.column.id)) { - width += leaf.column.getSize(); - } else { - break; - } - } - return width; -}; - -const calculateLeftOffset = (table, columnOrHeader) => { - const columnId = getColumnId(columnOrHeader); - const pinnedColumns = getPinnedColumnIds(table); - const currentPinnedIndex = pinnedColumns.indexOf(columnId); - - let leftOffset = 0; - - if (currentPinnedIndex > 0) { - for (let i = 0; i < currentPinnedIndex; i++) { - const prevColumn = table.getColumn(pinnedColumns[i]); - if (prevColumn) { - leftOffset += prevColumn.getSize(); - } - } - } - - return leftOffset; -}; - -const isColumnPin = (table, header) => { - const pinningColumn = getPinnedColumnIds(table); - const columnId = getColumnId(header); - const isPinned = pinningColumn.includes(columnId); - return isPinned; + return { + isPinned: Boolean(pinningPosition), + left: pinningPosition === 'left' ? column.getStart('left') : undefined, + right: pinningPosition === 'right' ? column.getAfter('right') : undefined, + }; }; const ColumnNumbersRow = ({ table, selectedColumnId, onColumnSelect }) => { @@ -102,10 +49,9 @@ const ColumnNumbersRow = ({ table, selectedColumnId, onColumnSelect }) => { }; const ColumnNumberCell = ({ column, table, selectedColumnId, onColumnSelect }) => { - const isPinned = isColumnPin(table, column); + const { isPinned, left, right } = getColumnPinnedStyles(column); const allLeafColumns = table.getAllLeafColumns(); const originalIndex = allLeafColumns.findIndex((col) => col.id === column.id); - const leftOffset = calculateLeftOffset(table, column); const selected = selectedColumnId === column.id; return ( @@ -133,7 +79,8 @@ const ColumnNumberCell = ({ column, table, selectedColumnId, onColumnSelect }) = border: '.063rem solid rgba(209, 213, 220, 1)', borderBottom: 'none', position: isPinned ? 'sticky' : 'relative', - left: isPinned ? leftOffset : 'auto', + left, + right, zIndex: selected ? 16 : isPinned ? 20 : 'auto', cursor: 'pointer', userSelect: 'none', @@ -161,15 +108,15 @@ const getColorBrightness = (hexColor) => { return 0.299 * r + 0.587 * g + 0.114 * b; }; -const HeaderCell = ({ header, table, onClick, onChangeWidth }) => { +const HeaderCell = ({ header, table, pinningPosition, onClick, onChangeWidth }) => { const column = header.column; - const pinningColumn = getPinnedColumnIds(table); - const isGroup = header.subHeaders?.length > 0; - const isPinned = isColumnPin(table, header); - const leftOffset = calculateLeftOffset(table, header); const headerSize = header.getSize(); - const pinnedBandWidth = isGroup ? getPinnedColumnsWidth(header, pinningColumn) : 0; - const splitGroup = isGroup && isPinned && pinnedBandWidth > 0 && pinnedBandWidth < headerSize; + const isPinned = Boolean(pinningPosition); + const leftOffset = pinningPosition === 'left' ? header.getStart() : undefined; + const rightOffset = + pinningPosition === 'right' + ? table.getRightTotalSize() - header.getStart() - headerSize + : undefined; const customBgColor = column.columnDef?.muiTableHeadCellProps?.sx?.backgroundColor; @@ -185,34 +132,24 @@ const HeaderCell = ({ header, table, onClick, onChangeWidth }) => { onChangeWidth(header, size + deltaWidth); }; - // Вычисляем отступ для текста внутри ячейки - // Если колонка закреплена, текст должен быть привязан к левому краю ячейки - // Если колонка не закреплена, текст должен начинаться после всех закрепленных колонок - const getTextLeftOffset = () => { - if (isPinned) { - return '0'; - } - // Для незакрепленных колонок текст должен начинаться после всех закрепленных - // Получаем общую ширину всех закрепленных колонок - const pinnedColumns = getPinnedColumnIds(table); - let totalPinnedWidth = 0; - for (const pinnedId of pinnedColumns) { - const pinnedCol = table.getColumn(pinnedId); - if (pinnedCol) { - totalPinnedWidth += pinnedCol.getSize(); - } - } - return `${totalPinnedWidth}px`; - }; - - const renderCell = (width, textLeft) => ( -
+ return ( +
onClick(header)} style={{ - width: width + 'px', - minWidth: width + 'px', - maxWidth: width + 'px', + width: '100%', + minWidth: '100%', + maxWidth: '100%', position: 'relative', padding: '4px 6px', textAlign: 'center', @@ -231,9 +168,7 @@ const HeaderCell = ({ header, table, onClick, onChangeWidth }) => {
{
); - - if (splitGroup) { - return ( - <> -
{renderCell(pinnedBandWidth, '0')}
-
{renderCell(headerSize - pinnedBandWidth, getTextLeftOffset(true))}
- - ); - } - - return ( -
- {renderCell(headerSize, getTextLeftOffset())} -
- ); }; const FilterRow = ({ table }) => { @@ -293,8 +208,7 @@ const FilterRow = ({ table }) => { // Компонент ячейки фильтра const FilterCell = ({ column, table }) => { - const isPinned = isColumnPin(table, column); - const leftOffset = calculateLeftOffset(table, column); + const { isPinned, left, right } = getColumnPinnedStyles(column); // Определяем тип фильтра на основе колонки const filterVariant = column.columnDef.filterVariant || 'text'; @@ -396,7 +310,8 @@ const FilterCell = ({ column, table }) => { border: '.063rem solid rgba(209, 213, 220, 1)', borderTop: 'none', position: isPinned ? 'sticky' : 'relative', - left: isPinned ? leftOffset : 'auto', + left, + right, zIndex: isPinned ? 19 : 'auto', }}> {renderFilterInput()} @@ -404,7 +319,7 @@ const FilterCell = ({ column, table }) => { ); }; -export const TableHead = ({ table, selectedColumnId, onColumnSelect, onChangeWidth, isLoadingData }) => { +const TableHeadComponent = ({ table, selectedColumnId, onColumnSelect, onChangeWidth, isLoadingData }) => { const handleHeaderClick = (header) => { if (!header.subHeaders?.length && header.column?.id) { onColumnSelect?.(header.column.id); @@ -415,6 +330,13 @@ export const TableHead = ({ table, selectedColumnId, onColumnSelect, onChangeWid return
; } + const headerSections = [ + { groups: table.getLeftHeaderGroups(), pinningPosition: 'left' }, + { groups: table.getCenterHeaderGroups(), pinningPosition: undefined }, + { groups: table.getRightHeaderGroups(), pinningPosition: 'right' }, + ]; + const headerRowsCount = Math.max(...headerSections.map(({ groups }) => groups.length)); + return ( <>
} - {/* Существующие строки заголовков */} - {table.getHeaderGroups().map((headerGroup) => ( -
- {headerGroup.headers.map((header) => ( - - - - ))} + {/* Pinned и обычные заголовки строятся отдельными группами. */} + {Array.from({ length: headerRowsCount }, (_, rowIndex) => ( +
+ {headerSections.flatMap(({ groups, pinningPosition }) => + (groups[rowIndex]?.headers || []).map((header) => ( + + )), + )}
))}
); }; + +export const TableHead = React.memo(TableHeadComponent); -- 2.47.2 From d347023211eb5df70df0245062b0ab100e580d25 Mon Sep 17 00:00:00 2001 From: PotapovaA Date: Wed, 19 Aug 2026 18:26:29 +0300 Subject: [PATCH 3/4] fix --- .../RealtimeTable/RealtimeTable.jsx | 18 ++--- .../SettingPanel/SettingPanel.jsx | 9 ++- .../RealtimeTable/contexts/RealtimeContext.js | 76 +++++++++++++++++-- .../RealtimeTable/hooks/useColumnSettings.js | 5 ++ 4 files changed, 86 insertions(+), 22 deletions(-) diff --git a/web/src/components/RealtimeTable/RealtimeTable.jsx b/web/src/components/RealtimeTable/RealtimeTable.jsx index 5e3ed64..97d2945 100644 --- a/web/src/components/RealtimeTable/RealtimeTable.jsx +++ b/web/src/components/RealtimeTable/RealtimeTable.jsx @@ -163,6 +163,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { handleUnpinColumn, handleSetColumnWidth, handleToggleColumnVisibility, + handleSetColumnsVisibility, } = useColumnSettings(`${formId}_${formType}_${sheetName}_${direction}`); const { headerPortalRef, containerRef } = useHeaderPortal(); @@ -174,6 +175,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { useEffect(() => { rowVirtualizerRef.current?.measure?.(); + columnVirtualizerRef.current?.measure?.(); }, [sizeMult]); useEffect(() => { @@ -300,10 +302,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { }, [columns, columnPinning, columnSizing, columnVisibility]); const selectedColumnIdRef = useRef(selectedColumnId); - - useEffect(() => { - selectedColumnIdRef.current = selectedColumnId; - }, [selectedColumnId]); + selectedColumnIdRef.current = selectedColumnId; const tableMeta = useMemo(() => ({ updateCell: handleUpdateCell, @@ -610,13 +609,13 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { return ( <> { onAddProgram={addProgram} onAddProject={addProject} onDeleteRow={handleDeleteRow} - isLoadingData={isLoadingData} formId={formId} sheetName={sheetName} direction={direction} diff --git a/web/src/components/RealtimeTable/SettingPanel/SettingPanel.jsx b/web/src/components/RealtimeTable/SettingPanel/SettingPanel.jsx index 1851c34..6647e02 100644 --- a/web/src/components/RealtimeTable/SettingPanel/SettingPanel.jsx +++ b/web/src/components/RealtimeTable/SettingPanel/SettingPanel.jsx @@ -37,11 +37,11 @@ const exportButtonSx = { height: '2.5rem', minHeight: '2.5rem' }; const SettingPanel = ({ selectedColumnId, columnPinning, - table, columns, onPinColumn, onUnpinColumn, onToggleColumnVisibility, + onSetColumnsVisibility, columnVisibility, columnsCurStage, onChangeSizeMult, @@ -53,7 +53,6 @@ const SettingPanel = ({ onAddProgram, onAddProject, onDeleteRow, - isLoadingData, formId, sheetName, direction, @@ -119,10 +118,12 @@ const SettingPanel = ({ console.warn('Нет столбцов для текущего этапа'); return; } + const visibilityByColumnId = {}; for (const column of leafColumns) { - onToggleColumnVisibility(column, showOnlyCurrentStageColumns || stageColumnIds.has(column.id)); + visibilityByColumnId[column.id] = showOnlyCurrentStageColumns || stageColumnIds.has(column.id); } - }, [columnsCurStage, leafColumns, onToggleColumnVisibility, showOnlyCurrentStageColumns, stageColumnIds]); + onSetColumnsVisibility(visibilityByColumnId); + }, [columnsCurStage, leafColumns, onSetColumnsVisibility, showOnlyCurrentStageColumns, stageColumnIds]); return ( <> diff --git a/web/src/components/RealtimeTable/contexts/RealtimeContext.js b/web/src/components/RealtimeTable/contexts/RealtimeContext.js index 7850f9c..397258e 100644 --- a/web/src/components/RealtimeTable/contexts/RealtimeContext.js +++ b/web/src/components/RealtimeTable/contexts/RealtimeContext.js @@ -3,21 +3,79 @@ import { useCallback, useContext, useEffect, + useMemo, useRef, useState, + useSyncExternalStore, } from "react"; import { toast } from "react-toastify"; import { useWebSocket } from "../hooks/useWebSocket"; import { getRowId } from "../utils/rowUtils"; -const RealtimeContext = createContext(null); +const RealtimeActionsContext = createContext(null); +const RealtimeConnectionContext = createContext(null); +const RealtimeVspOptionsContext = createContext(null); +const LockedCellsStoreContext = createContext(null); + +const useRequiredContext = (context, hookName) => { + const value = useContext(context); + if (value === null) { + throw new Error(`${hookName} must be used within RealtimeProvider`); + } + return value; +}; + +const createLockedCellsStore = () => { + let cells = new Set(); + const listeners = new Set(); + + const subscribe = (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }; + + const setCells = (nextValue) => { + const currentCells = [...cells]; + const resolvedValue = typeof nextValue === "function" ? nextValue(currentCells) : nextValue; + const nextCells = new Set(resolvedValue || []); + const hasChanges = + nextCells.size !== cells.size || [...nextCells].some((cellKey) => !cells.has(cellKey)); + + if (!hasChanges) return; + cells = nextCells; + listeners.forEach((listener) => listener()); + }; + + return { + subscribe, + setCells, + hasCell: (cellKey) => cells.has(cellKey), + getCells: () => [...cells], + }; +}; + +export const useRealtimeActions = () => + useRequiredContext(RealtimeActionsContext, "useRealtimeActions"); + +export const useRealtimeConnection = () => + useRequiredContext(RealtimeConnectionContext, "useRealtimeConnection"); + +export const useVspOptions = () => + useRequiredContext(RealtimeVspOptionsContext, "useVspOptions"); + +export const useCellLock = (cellKey) => { + const store = useRequiredContext(LockedCellsStoreContext, "useCellLock"); + const getSnapshot = useCallback(() => store.hasCell(cellKey), [cellKey, store]); + + return useSyncExternalStore(store.subscribe, getSnapshot, () => false); +}; export const useRealtime = () => { - const context = useContext(RealtimeContext); - if (!context) { - throw new Error("useRealtime must be used within RealtimeProvider"); - } - return context; + const actions = useRealtimeActions(); + const connection = useRealtimeConnection(); + const vspOptions = useVspOptions(); + + return { ...actions, ...connection, vspOptions }; }; export const RealtimeProvider = ({ @@ -41,9 +99,11 @@ export const RealtimeProvider = ({ : `/api/v1/ws/projects/${formId}/report/${year}/${sheetName}` }`; - //для ячеек которые редактируются другими пользователями - const [lockedCells, setLockedCells] = useState([]); const [vspOptions, setVspOptions] = useState([]); + const lockedCellsStoreRef = useRef(null); + if (lockedCellsStoreRef.current === null) { + lockedCellsStoreRef.current = createLockedCellsStore(); + } const handleMessage = useCallback((data) => { // Обработка ошибок diff --git a/web/src/components/RealtimeTable/hooks/useColumnSettings.js b/web/src/components/RealtimeTable/hooks/useColumnSettings.js index adc2640..a6c6741 100644 --- a/web/src/components/RealtimeTable/hooks/useColumnSettings.js +++ b/web/src/components/RealtimeTable/hooks/useColumnSettings.js @@ -66,6 +66,10 @@ export const useColumnSettings = (tableKey) => { setColumnVisibility((prev) => ({ ...prev, ...leafCols })); }, []); + const handleSetColumnsVisibility = useCallback((visibilityByColumnId) => { + setColumnVisibility((prev) => ({ ...prev, ...visibilityByColumnId })); + }, []); + // Опционально: функция для сброса всех настроек const resetAllSettings = useCallback(() => { setColumnSizing({}); @@ -84,6 +88,7 @@ export const useColumnSettings = (tableKey) => { handleUnpinColumn, handleSetColumnWidth, handleToggleColumnVisibility, + handleSetColumnsVisibility, resetAllSettings, // добавляем функцию сброса tableKey, // опционально возвращаем tableKey }; -- 2.47.2 From d9511ae7c54d48095d7c6b87c937bb382ed468d2 Mon Sep 17 00:00:00 2001 From: PotapovaA Date: Wed, 19 Aug 2026 18:38:54 +0300 Subject: [PATCH 4/4] fix --- .../RealtimeTable/Cell/Cell/Cell.jsx | 31 +- .../RealtimeTable/Cell/EditCell/EditCell.jsx | 6 +- .../Cell/EditCell/VspDropdownEditCell.jsx | 193 ++++++----- .../Modals/CreateProgramProjectModal.jsx | 10 +- .../Modals/SelectExpenseItemModal.jsx | 14 +- .../RealtimeTable/Modals/SelectVspModal.jsx | 12 +- .../RealtimeTable/RealtimeTable.jsx | 324 +++++++++--------- .../SettingPanel/SettingPanel.jsx | 38 +- .../SettingPanel/ValidationErrorsAlert.jsx | 4 +- .../RealtimeTable/SettingPanel/ZoomSlider.jsx | 26 +- .../RealtimeTable/TableHead/TableHead.jsx | 15 +- .../RealtimeTable/contexts/RealtimeContext.js | 317 ++++++++--------- .../RealtimeTable/hooks/useColumnSettings.js | 16 +- .../RealtimeTable/hooks/useRealtimeData.js | 230 ++++--------- .../RealtimeTable/hooks/useValidationRules.js | 35 +- .../components/RealtimeTable/tableColumns.jsx | 171 +++++---- web/src/components/common/CollapsibleTree.jsx | 23 +- web/src/components/common/SearchComponent.jsx | 4 +- 18 files changed, 693 insertions(+), 776 deletions(-) diff --git a/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx b/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx index 3e85f35..96970b1 100644 --- a/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx +++ b/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx @@ -1,5 +1,5 @@ import React, { useMemo, useCallback } from 'react'; -import { useRealtime } from '../../contexts/RealtimeContext'; +import { useCellLock } from '../../contexts/RealtimeContext'; // Константы вне компонента const BASE_CELL_STYLES = { @@ -136,35 +136,20 @@ const createHighlightedContent = (originalValue, displayValue, searchQueries) => }); }; -const CellComponent = ({ - cell, - row, - column, - onClick, - globalFilter, - columnFilter, - backgroundColor, - color, - isEditable, - isUpdating, - onCellNumberClick, - isInvalid, - vspOptions, -}) => { - const { lockedCells } = useRealtime(); +const CellComponent = ({ cell, row, column, onClick, globalFilter, columnFilter, backgroundColor, isEditable, isInvalid, vspOptions }) => { const cellKey = `${row.id}_${column.id}`; - const isLocked = lockedCells.includes(cellKey); + const isLocked = useCellLock(cellKey); const isVspDropdown = column?.columnDef?.editType === 'vsp_dropdown'; // Мемоизация значения - const { rawValue, displayValue, isNumeric } = useMemo(() => { + const { rawValue, displayValue } = useMemo(() => { const value = cell.getValue(); const isNum = value !== null && value !== undefined && value !== '' && !Number.isNaN(Number(value)); let display = String(value || ''); if (isVspDropdown && value != null) { - const vsp = vspOptions?.find(v => v.id === Number(value)); + const vsp = vspOptions?.find((v) => v.id === Number(value)); if (vsp) { return { rawValue: value, displayValue: vsp.registration_number, isNumeric: false }; } @@ -214,7 +199,7 @@ const CellComponent = ({ return ( // biome-ignore lint/a11y/useKeyWithClickEvents: -
+
{highlightedContent} {lockBadge}
@@ -232,10 +217,8 @@ const Cell = React.memo(CellComponent, (prevProps, nextProps) => { prevProps.backgroundColor === nextProps.backgroundColor && prevProps.isInvalid === nextProps.isInvalid && prevProps.isEditable === nextProps.isEditable && - prevProps.isUpdating === nextProps.isUpdating && - prevProps.onCellNumberClick === nextProps.onCellNumberClick && prevProps.vspOptions === nextProps.vspOptions ); }); -export default Cell; \ No newline at end of file +export default Cell; diff --git a/web/src/components/RealtimeTable/Cell/EditCell/EditCell.jsx b/web/src/components/RealtimeTable/Cell/EditCell/EditCell.jsx index ab70fe7..66ce216 100644 --- a/web/src/components/RealtimeTable/Cell/EditCell/EditCell.jsx +++ b/web/src/components/RealtimeTable/Cell/EditCell/EditCell.jsx @@ -1,5 +1,5 @@ import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; -import { useRealtime } from '../../contexts/RealtimeContext'; +import { useRealtimeActions } from '../../contexts/RealtimeContext'; import { calculateFormula, extractFormula, formatNumber, isFormula } from '../../utils/formulaUtils'; import { loadFromStorage, saveToStorage } from '../../utils/localStorageUtils'; @@ -30,7 +30,7 @@ const TEXTAREA_STYLES = { }; const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, table }) => { - const { endEditing: contextEndEditing } = useRealtime(); + const { endEditing: contextEndEditing } = useRealtimeActions(); const storageKey = `formula_${tableId}_${cellId}`; const [state, setState] = useState(() => ({ @@ -91,7 +91,7 @@ const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, ta const resize = () => { el.style.minHeight = '3rem'; el.style.height = '3rem'; - el.style.height = el.scrollHeight + 'px'; + el.style.height = `${el.scrollHeight}px`; el.style.maxHeight = '180px'; }; diff --git a/web/src/components/RealtimeTable/Cell/EditCell/VspDropdownEditCell.jsx b/web/src/components/RealtimeTable/Cell/EditCell/VspDropdownEditCell.jsx index 9c9e914..7af48c7 100644 --- a/web/src/components/RealtimeTable/Cell/EditCell/VspDropdownEditCell.jsx +++ b/web/src/components/RealtimeTable/Cell/EditCell/VspDropdownEditCell.jsx @@ -1,111 +1,120 @@ -import React, { memo, useEffect, useRef, useLayoutEffect, useState, useCallback, useMemo } from 'react'; -import { FormControl, Select, MenuItem } from '@mui/material'; -import { useRealtime } from '../../contexts/RealtimeContext'; +import { FormControl, MenuItem, Select } from '@mui/material'; +import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { useRealtimeActions, useVspOptions } from '../../contexts/RealtimeContext'; const EDITOR_STYLES = { - position: 'absolute', - zIndex: 12, - top: 0, + position: 'absolute', + zIndex: 12, + top: 0, }; const VspDropdownEditCell = memo(({ refCell, onChange, disabled, value, table }) => { - const { endEditing: contextEndEditing, vspOptions } = useRealtime(); + const { endEditing: contextEndEditing } = useRealtimeActions(); + const vspOptions = useVspOptions(); - const [selectedValue, setSelectedValue] = useState(() => value ?? ''); - const [position, setPosition] = useState(); - const [open, setOpen] = useState(false); + const [selectedValue, setSelectedValue] = useState(() => value ?? ''); + const [position, setPosition] = useState(); + const [open, setOpen] = useState(false); - const refFinished = useRef(false); + const refFinished = useRef(false); - useEffect(() => { - const timer = setTimeout(() => { - setOpen(true); - }, 80); - return () => clearTimeout(timer); - }, []); + useEffect(() => { + const timer = setTimeout(() => { + setOpen(true); + }, 80); + return () => clearTimeout(timer); + }, []); - useLayoutEffect(() => { - if (!refCell?.current) return; + useLayoutEffect(() => { + if (!refCell?.current) return; - const td = refCell.current.offsetParent; - const tr = td?.offsetParent; + const td = refCell.current.offsetParent; + const tr = td?.offsetParent; - if (td && tr) { - setPosition({ - left: td.offsetLeft, - width: td.offsetWidth, - translateY: tr.style.transform || '', - }); - } - }, [refCell]); + if (td && tr) { + setPosition({ + left: td.offsetLeft, + width: td.offsetWidth, + translateY: tr.style.transform || '', + }); + } + }, [refCell]); - const finishEditing = useCallback(() => { - if (refFinished.current) return; - refFinished.current = true; - table.setEditingCell(null); - const editingCell = table.getState().editingCell; - if (editingCell) { - contextEndEditing?.(editingCell.row, editingCell.column); - } - }, [table, contextEndEditing]); + const finishEditing = useCallback(() => { + if (refFinished.current) return; + refFinished.current = true; + table.setEditingCell(null); + const editingCell = table.getState().editingCell; + if (editingCell) { + contextEndEditing?.(editingCell.row, editingCell.column); + } + }, [table, contextEndEditing]); - const handleSelectChange = useCallback((e) => { - const val = e.target.value; - if (val == null || val === '') return; - refFinished.current = true; - setSelectedValue(val); - onChange?.(val); - const editingCell = table.getState().editingCell; - if (editingCell) { - contextEndEditing?.(editingCell.row, editingCell.column); - } - }, [onChange, table, contextEndEditing]); + const handleSelectChange = useCallback( + (e) => { + const val = e.target.value; + if (val == null || val === '') return; + refFinished.current = true; + setSelectedValue(val); + onChange?.(val); + const editingCell = table.getState().editingCell; + if (editingCell) { + contextEndEditing?.(editingCell.row, editingCell.column); + } + }, + [onChange, table, contextEndEditing], + ); - const handleSelectClose = useCallback((_event, reason) => { - if (reason === 'selectOption') return; - finishEditing(); - }, [finishEditing]); + const handleSelectClose = useCallback( + (_event, reason) => { + if (reason === 'selectOption') return; + finishEditing(); + }, + [finishEditing], + ); - const editorStyles = useMemo(() => ({ - ...EDITOR_STYLES, - left: position?.left || 0, - width: position?.width || 0, - transform: position?.translateY || 0, - }), [position]); + const editorStyles = useMemo( + () => ({ + ...EDITOR_STYLES, + left: position?.left || 0, + width: position?.width || 0, + transform: position?.translateY || 0, + }), + [position], + ); - return ( - - - {position && -
- - - -
- } - - - ); + return ( + + + {position && ( +
+ + + +
+ )} + + + ); }); VspDropdownEditCell.displayName = 'VspDropdownEditCell'; diff --git a/web/src/components/RealtimeTable/Modals/CreateProgramProjectModal.jsx b/web/src/components/RealtimeTable/Modals/CreateProgramProjectModal.jsx index 6d4ab17..fa96cd6 100644 --- a/web/src/components/RealtimeTable/Modals/CreateProgramProjectModal.jsx +++ b/web/src/components/RealtimeTable/Modals/CreateProgramProjectModal.jsx @@ -1,11 +1,11 @@ import { Box, Divider, IconButton, Stack } from '@mui/material'; -import { useEffect, useState } from 'react'; +import { memo, useEffect, useState } from 'react'; import { DangerOutlinedButton, PrimaryButton } from '../../common/Buttons/Buttons'; import { HeaderContainer, ModalBackdrop, ModalContainer, ModalTitle } from '../../common/Modal/ModalStyled'; import { StyledTextField } from '../../common/StyledTextField'; import { Cancel } from '../../common/icons/icons'; -export const CreateProgramModal = ({ isOpen, onClose, onCreate, title = '', isSaving = false, sheet }) => { +const CreateProgramModalComponent = ({ isOpen, onClose, onCreate, title = '', isSaving = false }) => { const [name, setName] = useState(''); useEffect(() => { @@ -36,7 +36,7 @@ export const CreateProgramModal = ({ isOpen, onClose, onCreate, title = '', isSa return ( - + {title} @@ -50,7 +50,7 @@ export const CreateProgramModal = ({ isOpen, onClose, onCreate, title = '', isSa {/* setName(e.target.value)}> + setName(e.target.value)} /> ); }; + +export const CreateProgramModal = memo(CreateProgramModalComponent); diff --git a/web/src/components/RealtimeTable/Modals/SelectExpenseItemModal.jsx b/web/src/components/RealtimeTable/Modals/SelectExpenseItemModal.jsx index df1fdd2..de0f2a3 100644 --- a/web/src/components/RealtimeTable/Modals/SelectExpenseItemModal.jsx +++ b/web/src/components/RealtimeTable/Modals/SelectExpenseItemModal.jsx @@ -1,17 +1,17 @@ import { Autocomplete, Box, Divider, FormControl, IconButton, Stack, TextField } from '@mui/material'; -import { useEffect, useState } from 'react'; +import { memo, useEffect, useState } from 'react'; import { toast } from 'react-toastify'; import { ExpenseItemApi } from '../../../api/expense-item'; import { DangerOutlinedButton, PrimaryButton } from '../../common/Buttons/Buttons'; import { HeaderContainer, ModalBackdrop, ModalContainer, ModalTitle } from '../../common/Modal/ModalStyled'; import { Cancel } from '../../common/icons/icons'; -export const SelectExpenseItemModal = ({ isOpen, onClose, onSelect, formId, title = '', isSaving = false, sheet }) => { +const SelectExpenseItemModalComponent = ({ isOpen, onClose, onSelect, formId, title = '', isSaving = false, sheet }) => { const [selectedValue, setSelectedValue] = useState(''); const [expenseItemOptions, setExpenseItemOption] = useState([]); useEffect(() => { - if (formId === null) return; + if (!isOpen || formId == null) return; ExpenseItemApi.getAll({ r_start: true, sheet: sheet }).then((data) => { if (data.success) { setExpenseItemOption(data.result); @@ -19,7 +19,7 @@ export const SelectExpenseItemModal = ({ isOpen, onClose, onSelect, formId, titl } toast.error('Ошибка получения статья расходов'); }); - }, [formId]); + }, [formId, isOpen, sheet]); useEffect(() => { if (!isOpen) { @@ -49,7 +49,7 @@ export const SelectExpenseItemModal = ({ isOpen, onClose, onSelect, formId, titl return ( - + {title} @@ -72,7 +72,7 @@ export const SelectExpenseItemModal = ({ isOpen, onClose, onSelect, formId, titl getOptionLabel={(option) => option.name || ''} getOptionKey={(option) => option.id} value={selectedValue} - onChange={(event, newValue) => { + onChange={(_event, newValue) => { setSelectedValue(newValue); }} slotProps={{ @@ -118,3 +118,5 @@ export const SelectExpenseItemModal = ({ isOpen, onClose, onSelect, formId, titl ); }; + +export const SelectExpenseItemModal = memo(SelectExpenseItemModalComponent); diff --git a/web/src/components/RealtimeTable/Modals/SelectVspModal.jsx b/web/src/components/RealtimeTable/Modals/SelectVspModal.jsx index ce8bb96..88cd4da 100644 --- a/web/src/components/RealtimeTable/Modals/SelectVspModal.jsx +++ b/web/src/components/RealtimeTable/Modals/SelectVspModal.jsx @@ -1,17 +1,17 @@ import { Box, Divider, FormControl, IconButton, MenuItem, Select, Stack } from '@mui/material'; -import { useEffect, useState } from 'react'; +import { memo, useEffect, useState } from 'react'; import { toast } from 'react-toastify'; import { DictVspApi } from '../../../api/dict-vsp'; import { DangerOutlinedButton, PrimaryButton } from '../../common/Buttons/Buttons'; import { HeaderContainer, ModalBackdrop, ModalContainer, ModalTitle } from '../../common/Modal/ModalStyled'; import { Cancel } from '../../common/icons/icons'; -export const SelectVspModal = ({ isOpen, onClose, onSelect, formId, title = 'Выбор ВСП', isSaving = false }) => { +const SelectVspModalComponent = ({ isOpen, onClose, onSelect, formId, title = 'Выбор ВСП', isSaving = false }) => { const [selectedValue, setSelectedValue] = useState(''); const [vspOptions, setVspOption] = useState([]); useEffect(() => { - if (formId === null) return; + if (!isOpen || formId == null) return; DictVspApi.getDropdownVsp({ form_id: formId }).then((data) => { if (data.success) { setVspOption(data.result); @@ -19,7 +19,7 @@ export const SelectVspModal = ({ isOpen, onClose, onSelect, formId, title = 'В } toast.error('Ошибка получения ВСП'); }); - }, [formId]); + }, [formId, isOpen]); useEffect(() => { if (!isOpen) { @@ -49,7 +49,7 @@ export const SelectVspModal = ({ isOpen, onClose, onSelect, formId, title = 'В return ( - + {title} @@ -99,3 +99,5 @@ export const SelectVspModal = ({ isOpen, onClose, onSelect, formId, title = 'В ); }; + +export const SelectVspModal = memo(SelectVspModalComponent); diff --git a/web/src/components/RealtimeTable/RealtimeTable.jsx b/web/src/components/RealtimeTable/RealtimeTable.jsx index 97d2945..c55aed6 100644 --- a/web/src/components/RealtimeTable/RealtimeTable.jsx +++ b/web/src/components/RealtimeTable/RealtimeTable.jsx @@ -1,40 +1,32 @@ +import { MaterialReactTable, useMaterialReactTable } from 'material-react-table'; import React, { useState, useMemo, useRef, useEffect, useCallback, useTransition } from 'react'; import { createPortal } from 'react-dom'; -import { - useMaterialReactTable, - MaterialReactTable, -} from 'material-react-table'; -import useRealtimeData from './hooks/useRealtimeData'; -import { getTableColumns } from './tableColumns'; -import { Cell, EditCell } from './index'; -import { TableHead } from './TableHead/TableHead'; -import SettingsPanel from './SettingPanel/SettingPanel'; import { ColumnSelectionOverlay } from './ColumnSelectionOverlay/ColumnSelectionOverlay'; +import SettingsPanel from './SettingPanel/SettingPanel'; +import { TableHead } from './TableHead/TableHead'; +import useRealtimeData from './hooks/useRealtimeData'; +import { Cell, EditCell } from './index'; +import { getTableColumns } from './tableColumns'; -import { useColumnSettings } from './hooks/useColumnSettings'; -import { useTableScale } from './hooks/useTableScale'; -import { useHeaderPortal } from './hooks/useHeaderPortal'; -import { useValidationRules } from './hooks/useValidationRules'; -import { - BASE_TABLE_CONFIG, - getTableBodyCellProps, - getTablePaperStyles, - TABLE_ROW_HEIGHT, -} from './constants/tableConfig'; -import { useRealtime } from './contexts/RealtimeContext'; -import { toast } from 'react-toastify'; import { CircularProgress } from '@mui/material'; -import { additionExpenseRowTable, additionVspRowTable } from './constants/addingRowConfig'; -import { SelectVspModal } from './Modals/SelectVspModal'; -import { SelectExpenseItemModal } from './Modals/SelectExpenseItemModal'; -import { getRowId } from './utils/rowUtils'; import { debounce } from '@mui/material'; +import { toast } from 'react-toastify'; import { CreateProgramModal } from './Modals/CreateProgramProjectModal'; +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 { useColumnSettings } from './hooks/useColumnSettings'; +import { useHeaderPortal } from './hooks/useHeaderPortal'; +import { useTableScale } from './hooks/useTableScale'; +import { useValidationRules } from './hooks/useValidationRules'; +import { getRowId } from './utils/rowUtils'; -import { FormsNoCanAddRow } from './constants/formConfig'; import { DictVspApi } from '../../api/dict-vsp'; import { useAuth } from '../../app/context/AuthProvider'; +import { FormsNoCanAddRow } from './constants/formConfig'; const CONFIG_CACHE = new Map(); @@ -65,11 +57,7 @@ const getColumnVirtualizerOptions = ({ table }) => { }; const hasVspDropdown = (columns) => - columns.some( - (column) => - column.editType === 'vsp_dropdown' || - (column.columns?.length && hasVspDropdown(column.columns)), - ); + columns.some((column) => column.editType === 'vsp_dropdown' || (column.columns?.length && hasVspDropdown(column.columns))); const tableContentStyle = { position: 'relative', @@ -91,7 +79,13 @@ const loadingOverlayStyle = { const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { const { user } = useAuth(); - const { data, columnsCurStage, isLoading: isTableLoading, editingCells: dataEditingCells } = useRealtimeData(formId, sheetName, direction, formType, year); + const userRoleId = user?.role_id; + const { + data, + columnsCurStage, + isLoading: isTableLoading, + editingCells: dataEditingCells, + } = useRealtimeData(formId, sheetName, direction, formType, year); const [globalFilter, setGlobalFilter] = useState(''); const [showColumnFilters, setShowColumnFilters] = useState(false); const [selectedColumnId, setSelectedColumnId] = useState(); @@ -101,12 +95,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { const [, startTransition] = useTransition(); const [editingCell, setEditingCell] = useState(null); const [columnsConfig, setColumnsConfig] = useState(null); - const { errors: validationErrors, isCellInvalid } = useValidationRules( - data, - columnsConfig?.columns, - formType, - sheetName, - ); + const { errors: validationErrors, isCellInvalid } = useValidationRules(data, columnsConfig?.columns, formType, sheetName); const [expanded, setExpanded] = useState(true); const [showOnlyDepth2, setShowOnlyDepth2] = useState(false); @@ -115,10 +104,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { const [isOpenModalSelectExpenseItem, setIsOpenModalSelectExpenseItem] = useState(false); const [createModalType, setCreateModalType] = useState(null); - const isFormNoCanAddRow = useMemo( - () => FormsNoCanAddRow[formType]?.includes(sheetName) ?? false, - [formType, sheetName], - ); + const isFormNoCanAddRow = useMemo(() => FormsNoCanAddRow[formType]?.includes(sheetName) ?? false, [formType, sheetName]); const isVspAdditionRow = useMemo(() => { if (!formType || !sheetName) return false; @@ -133,11 +119,12 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { }, [formType, sheetName]); const handleGlobalFilterChange = useMemo( - () => debounce((value) => { - startTransition(() => { - setGlobalFilter(value); - }); - }, 300), + () => + debounce((value) => { + startTransition(() => { + setGlobalFilter(value); + }); + }, 300), [startTransition], ); @@ -151,7 +138,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { addProgram: contextAddProgram, addProject: contextAddProject, setVspOptions, - } = useRealtime(); + } = useRealtimeActions(); const { columnSizing, @@ -170,8 +157,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { const rowVirtualizerRef = useRef(null); const columnVirtualizerRef = useRef(null); - const { sizeMult, setSizeMult, tableScaleStyle, tableWrapperStyle } = - useTableScale(); + const { sizeMult, setSizeMult, tableScaleStyle, tableWrapperStyle } = useTableScale(); useEffect(() => { rowVirtualizerRef.current?.measure?.(); @@ -180,6 +166,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { useEffect(() => { if (!formType || !sheetName) return; + let isCancelled = false; const cacheKey = `${formType}_${sheetName}`; if (CONFIG_CACHE.has(cacheKey)) { @@ -191,38 +178,48 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { try { const { config } = await import(`./constants/${formType}/${sheetName}.js`); CONFIG_CACHE.set(cacheKey, config.config); - setColumnsConfig(config.config); + if (!isCancelled) setColumnsConfig(config.config); } catch (error) { console.error(`Failed to load config for ${formType}/${sheetName}:`, error); - setColumnsConfig({ columns: [], colors: {} }); + if (!isCancelled) setColumnsConfig({ columns: [], colors: {} }); } }; loadConfig(); + return () => { + isCancelled = true; + }; }, [formType, sheetName]); useEffect(() => { if (!formId || !columnsConfig?.columns) return; if (!hasVspDropdown(columnsConfig.columns)) return; + let isCancelled = false; - DictVspApi.getDropdownVsp({ form_id: formId }).then(data => { - if (data.success) { + DictVspApi.getDropdownVsp({ form_id: formId }).then((data) => { + if (!isCancelled && data.success) { setVspOptions(data.result); } }); + + return () => { + isCancelled = true; + }; }, [formId, columnsConfig, setVspOptions]); - - const handleUpdateCell = useCallback(async (row, column, value) => { - return await contextUpdateCell(row, column, value); - }, [contextUpdateCell]); + const handleUpdateCell = useCallback( + (row, column, value) => { + return contextUpdateCell(row, column, value); + }, + [contextUpdateCell], + ); const handleClickRowCell = useCallback((rowId) => { setRowSelection((prev) => ({ [rowId]: !prev[rowId], })); - }, []) + }, []); const handleColumnSelect = useCallback((columnId) => { setSelectedColumnId((prev) => { @@ -259,8 +256,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { EditCell, columnsConfig, columnsCurStage, - user, - onCellUpdate: handleUpdateCell, + userRoleId, onCellNumberClick: handleClickRowCell, onCellUpdateError: handleCellUpdateError, isCellInvalid, @@ -269,15 +265,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { console.error('Error creating columns:', error); return []; } - }, [ - columnsConfig, - columnsCurStage, - user, - handleCellUpdateError, - handleClickRowCell, - handleUpdateCell, - isCellInvalid, - ]); + }, [columnsConfig, columnsCurStage, userRoleId, handleCellUpdateError, handleClickRowCell, handleUpdateCell, isCellInvalid]); // MRT запоминает индексы pinned-колонок до завершения асинхронной // загрузки конфигурации. Новая ссылка на columnPinning заставляет @@ -304,10 +292,13 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { const selectedColumnIdRef = useRef(selectedColumnId); selectedColumnIdRef.current = selectedColumnId; - const tableMeta = useMemo(() => ({ - updateCell: handleUpdateCell, - getSelectedColumnId: () => selectedColumnIdRef.current, - }), [handleUpdateCell]); + const tableMeta = useMemo( + () => ({ + updateCell: handleUpdateCell, + getSelectedColumnId: () => selectedColumnIdRef.current, + }), + [handleUpdateCell], + ); const createTableConfig = ({ columns, @@ -355,7 +346,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { showColumnFilters, rowSelection, editingCell, - expanded + expanded, }, initialState: { expanded: true, @@ -377,7 +368,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { minHeight: '1px', maxHeight: '1px', visibility: 'hidden', - } + }, }, }, muiTablePaperProps: getTablePaperStyles(), @@ -396,69 +387,74 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { }, }); - const tableConfig = useMemo(() => createTableConfig({ - columns, - data, - columnSizing, - columnPinning, - columnVisibility, - expanded, - globalFilter, - showColumnFilters, - rowSelection, - containerRef, - tableMeta, - setColumnSizing, - setColumnPinning, - setExpanded, - editingCell, - contextStartEditing, - }), [ - columns, - data, - columnSizing, - columnPinning, - columnVisibility, - globalFilter, - showColumnFilters, - rowSelection, - containerRef, - tableMeta, - setColumnSizing, - setColumnPinning, - editingCell, - expanded, - ]); + const tableConfig = useMemo( + () => + createTableConfig({ + columns, + data, + columnSizing, + columnPinning, + columnVisibility, + expanded, + globalFilter, + showColumnFilters, + rowSelection, + containerRef, + tableMeta, + setColumnSizing, + setColumnPinning, + setExpanded, + editingCell, + contextStartEditing, + }), + [ + columns, + data, + columnSizing, + columnPinning, + columnVisibility, + globalFilter, + showColumnFilters, + rowSelection, + containerRef, + tableMeta, + setColumnSizing, + setColumnPinning, + editingCell, + expanded, + ], + ); const table = useMaterialReactTable(tableConfig); const isLoadingData = columns.length > 0; - const handleNavigateToColumn = useCallback((columnId) => { - if (!columnId) return; + const handleNavigateToColumn = useCallback( + (columnId) => { + if (!columnId) return; - setSelectedColumnId(columnId); + setSelectedColumnId(columnId); - const container = containerRef.current; - if (!container) return; + const container = containerRef.current; + if (!container) return; - const pinnedIds = new Set(columnPinning?.left || []); - if (pinnedIds.has(columnId)) return; + const pinnedIds = new Set(columnPinning?.left || []); + if (pinnedIds.has(columnId)) return; - const centerColumns = table - .getVisibleLeafColumns() - .filter((column) => !pinnedIds.has(column.id)); + 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(); - } + let offset = 0; + for (const column of centerColumns) { + if (column.id === columnId) break; + offset += column.getSize(); + } - container.scrollTo({ - left: Math.max(0, offset - 40), - behavior: 'smooth', - }); - }, [columnPinning, containerRef, table]); + container.scrollTo({ + left: Math.max(0, offset - 40), + behavior: 'smooth', + }); + }, + [columnPinning, containerRef, table], + ); useEffect(() => { if (!dataEditingCells?.line_id) { @@ -470,9 +466,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { const animationFrameId = requestAnimationFrame(() => { try { const row = table.getRow(dataEditingCells.line_id); - const cell = row?.getVisibleCells().find( - c => c.column.id === dataEditingCells.column - ); + const cell = row?.getVisibleCells().find((c) => c.column.id === dataEditingCells.column); if (cell) setEditingCell(cell); } catch (error) { console.error(error); @@ -482,10 +476,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { return () => cancelAnimationFrame(animationFrameId); }, [dataEditingCells, editingCell, table]); - const getSelectedRow = useCallback( - () => table.getRowModel().rows.find((row) => rowSelectionRef.current[row.id]), - [table], - ); + const getSelectedRow = useCallback(() => table.getRowModel().rows.find((row) => rowSelectionRef.current[row.id]), [table]); const handleAddRow = useCallback(() => { const row = getSelectedRow(); @@ -496,25 +487,37 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { contextAddRow({ expense_item_id: row.original.data.header.expense_item_id }); }, [contextAddRow, getSelectedRow]); - const handleAddVspRow = useCallback((vsp_id) => { - contextAddRow({ vsp_id }); - }, [contextAddRow]); + const handleAddVspRow = useCallback( + (vsp_id) => { + contextAddRow({ vsp_id }); + }, + [contextAddRow], + ); - const handleAddExpenseItemRow = useCallback((expense_item) => { - const row = getSelectedRow(); - if (!row) return; - contextAddRow({ expense_item_id: expense_item.id, project_id: row.original.data.header.project_id }); - }, [contextAddRow, getSelectedRow]); + const handleAddExpenseItemRow = useCallback( + (expense_item) => { + const row = getSelectedRow(); + if (!row) return; + contextAddRow({ expense_item_id: expense_item.id, project_id: row.original.data.header.project_id }); + }, + [contextAddRow, getSelectedRow], + ); - const handleAddProgramRow = useCallback((name) => { - contextAddProgram({ name }); - }, [contextAddProgram]); + const handleAddProgramRow = useCallback( + (name) => { + contextAddProgram({ name }); + }, + [contextAddProgram], + ); - const handleAddProjectRow = useCallback((name) => { - const row = getSelectedRow(); - if (!row) return; - contextAddProject({ name, program_id: row.original.data.header.program_id }); - }, [contextAddProject, getSelectedRow]); + const handleAddProjectRow = useCallback( + (name) => { + const row = getSelectedRow(); + if (!row) return; + contextAddProject({ name, program_id: row.original.data.header.program_id }); + }, + [contextAddProject, getSelectedRow], + ); const handleOpenModalSelectVsp = useCallback(() => { setIsOpenModalSelectVsp(true); @@ -582,7 +585,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { }, []); const handleToggleDepthVisibility = useCallback(() => { - setShowOnlyDepth2(prev => { + setShowOnlyDepth2((prev) => { const newState = !prev; if (newState) { @@ -609,13 +612,13 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { return ( <> { columnPinning={columnPinning} columnSizing={columnSizing} columnVisibility={columnVisibility} + columnFilters={table.getState().columnFilters} showColumnFilters={showColumnFilters} selectedColumnId={selectedColumnId} onColumnSelect={handleColumnSelect} @@ -683,14 +687,14 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { onClose={closeSelectVspModal} formId={formId} onSelect={handleAddVspRow} - title="Выбор ВСП" + title='Выбор ВСП' /> Boolean(selectedColumnId && columnPinning?.left?.includes(selectedColumnId)), [columnPinning, selectedColumnId], ); - const selectedColumnIds = useMemo( - () => Object.keys(columnVisibility).filter((key) => !columnVisibility[key]), - [columnVisibility], - ); - - //TO DO: сделать - const { addColorForCells } = {}; + const selectedColumnIds = useMemo(() => Object.keys(columnVisibility).filter((key) => !columnVisibility[key]), [columnVisibility]); const handleChangeShowColumnFilters = useCallback(() => { onChangeShowColumnFilters(!showColumnFilters); @@ -102,10 +96,7 @@ const SettingPanel = ({ isPinned ? onUnpinColumn?.(selectedColumnId) : onPinColumn?.(selectedColumnId); }, [isPinned, onPinColumn, onUnpinColumn, selectedColumnId]); const leafColumns = useMemo(() => collectLeafColumns({ columns }), [columns]); - const stageColumnIds = useMemo( - () => new Set([...stageColumnsHiddenFromPicker, ...(columnsCurStage || [])]), - [columnsCurStage], - ); + const stageColumnIds = useMemo(() => new Set([...stageColumnsHiddenFromPicker, ...(columnsCurStage || [])]), [columnsCurStage]); const showOnlyCurrentStageColumns = useMemo(() => { if (!columnsCurStage?.length || !leafColumns.length) return false; return leafColumns.every((column) => @@ -125,6 +116,14 @@ const SettingPanel = ({ onSetColumnsVisibility(visibilityByColumnId); }, [columnsCurStage, leafColumns, onSetColumnsVisibility, showOnlyCurrentStageColumns, stageColumnIds]); + const handleSetAllColumnsVisibility = useCallback( + (columnIds, isVisible) => { + const visibilityByColumnId = Object.fromEntries(columnIds.map((columnId) => [columnId, isVisible])); + onSetColumnsVisibility(visibilityByColumnId); + }, + [onSetColumnsVisibility], + ); + return ( <> @@ -145,7 +144,6 @@ const SettingPanel = ({ - {!isFormNoCanAddRow && ( @@ -193,7 +191,12 @@ const SettingPanel = ({ {columns ? ( - + ) : ( '' )} @@ -204,8 +207,7 @@ const SettingPanel = ({ color='success' data-active={showOnlyCurrentStageColumns} onClick={handleToggleCurrentStageColumns} - disabled={!columnsCurStage || columnsCurStage.length === 0} - > + disabled={!columnsCurStage || columnsCurStage.length === 0}> {showOnlyCurrentStageColumns ? : } @@ -241,7 +243,7 @@ const SettingPanel = ({ - + ); }; diff --git a/web/src/components/RealtimeTable/SettingPanel/ValidationErrorsAlert.jsx b/web/src/components/RealtimeTable/SettingPanel/ValidationErrorsAlert.jsx index d381fd6..18377d2 100644 --- a/web/src/components/RealtimeTable/SettingPanel/ValidationErrorsAlert.jsx +++ b/web/src/components/RealtimeTable/SettingPanel/ValidationErrorsAlert.jsx @@ -1,7 +1,7 @@ import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; import WarningAmberRoundedIcon from '@mui/icons-material/WarningAmberRounded'; import { Button, Menu, MenuItem, Stack } from '@mui/material'; -import { useEffect, useState } from 'react'; +import { memo, useEffect, useState } from 'react'; const warningColors = { bg: '#fff7e6', @@ -115,4 +115,4 @@ const ValidationErrorsAlert = ({ errors = [], onNavigateToColumn }) => { ); }; -export default ValidationErrorsAlert; +export default memo(ValidationErrorsAlert); diff --git a/web/src/components/RealtimeTable/SettingPanel/ZoomSlider.jsx b/web/src/components/RealtimeTable/SettingPanel/ZoomSlider.jsx index feb739a..25165fb 100644 --- a/web/src/components/RealtimeTable/SettingPanel/ZoomSlider.jsx +++ b/web/src/components/RealtimeTable/SettingPanel/ZoomSlider.jsx @@ -1,32 +1,32 @@ import { Box, Slider } from '@mui/material'; -import { useState } from 'react'; +import { memo } from 'react'; + +const sliderContainerSx = { width: 200, height: '2.5rem', display: 'flex', alignItems: 'center', gap: 2 }; +const zoomMarks = [ + { value: 0.5, label: '50%' }, + { value: 1, label: '100%' }, + { value: 2, label: '200%' }, +]; const ZoomSlider = ({ onChange, width = 200, curScale }) => { - const [scale, setScale] = useState(curScale); - - const handleChange = (event, newValue) => { - setScale(newValue); + const handleChange = (_event, newValue) => { if (onChange) onChange(newValue); }; return ( - + `${Math.round(value * 100)}%`} - marks={[ - { value: 0.5, label: '50%' }, - { value: 1, label: '100%' }, - { value: 2, label: '200%' }, - ]} + marks={zoomMarks} /> ); }; -export default ZoomSlider; +export default memo(ZoomSlider); diff --git a/web/src/components/RealtimeTable/TableHead/TableHead.jsx b/web/src/components/RealtimeTable/TableHead/TableHead.jsx index 9910a5e..8a2385c 100644 --- a/web/src/components/RealtimeTable/TableHead/TableHead.jsx +++ b/web/src/components/RealtimeTable/TableHead/TableHead.jsx @@ -2,11 +2,7 @@ import React, { useMemo } from 'react'; import ColumnResizer from './ColumnResizer'; const getSortedColumns = (table) => { - return [ - ...table.getLeftVisibleLeafColumns(), - ...table.getCenterVisibleLeafColumns(), - ...table.getRightVisibleLeafColumns(), - ]; + return [...table.getLeftVisibleLeafColumns(), ...table.getCenterVisibleLeafColumns(), ...table.getRightVisibleLeafColumns()]; }; const getColumnPinnedStyles = (column) => { @@ -113,10 +109,7 @@ const HeaderCell = ({ header, table, pinningPosition, onClick, onChangeWidth }) const headerSize = header.getSize(); const isPinned = Boolean(pinningPosition); const leftOffset = pinningPosition === 'left' ? header.getStart() : undefined; - const rightOffset = - pinningPosition === 'right' - ? table.getRightTotalSize() - header.getStart() - headerSize - : undefined; + const rightOffset = pinningPosition === 'right' ? table.getRightTotalSize() - header.getStart() - headerSize : undefined; const customBgColor = column.columnDef?.muiTableHeadCellProps?.sx?.backgroundColor; @@ -200,14 +193,14 @@ const FilterRow = ({ table }) => { backgroundColor: 'white', }}> {filteredColumns.map((column) => ( - + ))}
); }; // Компонент ячейки фильтра -const FilterCell = ({ column, table }) => { +const FilterCell = ({ column }) => { const { isPinned, left, right } = getColumnPinnedStyles(column); // Определяем тип фильтра на основе колонки diff --git a/web/src/components/RealtimeTable/contexts/RealtimeContext.js b/web/src/components/RealtimeTable/contexts/RealtimeContext.js index 397258e..6d86f9c 100644 --- a/web/src/components/RealtimeTable/contexts/RealtimeContext.js +++ b/web/src/components/RealtimeTable/contexts/RealtimeContext.js @@ -1,16 +1,7 @@ -import { - createContext, - useCallback, - useContext, - useEffect, - useMemo, - useRef, - useState, - useSyncExternalStore, -} from "react"; -import { toast } from "react-toastify"; -import { useWebSocket } from "../hooks/useWebSocket"; -import { getRowId } from "../utils/rowUtils"; +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; +import { toast } from 'react-toastify'; +import { useWebSocket } from '../hooks/useWebSocket'; +import { getRowId } from '../utils/rowUtils'; const RealtimeActionsContext = createContext(null); const RealtimeConnectionContext = createContext(null); @@ -27,47 +18,55 @@ const useRequiredContext = (context, hookName) => { const createLockedCellsStore = () => { let cells = new Set(); - const listeners = new Set(); + const listenersByCell = new Map(); - const subscribe = (listener) => { + const subscribeCell = (cellKey, listener) => { + const listeners = listenersByCell.get(cellKey) || new Set(); listeners.add(listener); - return () => listeners.delete(listener); + listenersByCell.set(cellKey, listeners); + + return () => { + listeners.delete(listener); + if (listeners.size === 0) listenersByCell.delete(cellKey); + }; }; const setCells = (nextValue) => { const currentCells = [...cells]; - const resolvedValue = typeof nextValue === "function" ? nextValue(currentCells) : nextValue; + const resolvedValue = typeof nextValue === 'function' ? nextValue(currentCells) : nextValue; const nextCells = new Set(resolvedValue || []); - const hasChanges = - nextCells.size !== cells.size || [...nextCells].some((cellKey) => !cells.has(cellKey)); + const changedCells = new Set([ + ...[...cells].filter((cellKey) => !nextCells.has(cellKey)), + ...[...nextCells].filter((cellKey) => !cells.has(cellKey)), + ]); - if (!hasChanges) return; + if (changedCells.size === 0) return; cells = nextCells; - listeners.forEach((listener) => listener()); + changedCells.forEach((cellKey) => { + listenersByCell.get(cellKey)?.forEach((listener) => listener()); + }); }; return { - subscribe, + subscribeCell, setCells, hasCell: (cellKey) => cells.has(cellKey), getCells: () => [...cells], }; }; -export const useRealtimeActions = () => - useRequiredContext(RealtimeActionsContext, "useRealtimeActions"); +export const useRealtimeActions = () => useRequiredContext(RealtimeActionsContext, 'useRealtimeActions'); -export const useRealtimeConnection = () => - useRequiredContext(RealtimeConnectionContext, "useRealtimeConnection"); +export const useRealtimeConnection = () => useRequiredContext(RealtimeConnectionContext, 'useRealtimeConnection'); -export const useVspOptions = () => - useRequiredContext(RealtimeVspOptionsContext, "useVspOptions"); +export const useVspOptions = () => useRequiredContext(RealtimeVspOptionsContext, 'useVspOptions'); export const useCellLock = (cellKey) => { - const store = useRequiredContext(LockedCellsStoreContext, "useCellLock"); + const store = useRequiredContext(LockedCellsStoreContext, 'useCellLock'); + const subscribe = useCallback((listener) => store.subscribeCell(cellKey, listener), [cellKey, store]); const getSnapshot = useCallback(() => store.hasCell(cellKey), [cellKey, store]); - return useSyncExternalStore(store.subscribe, getSnapshot, () => false); + return useSyncExternalStore(subscribe, getSnapshot, () => false); }; export const useRealtime = () => { @@ -78,24 +77,13 @@ export const useRealtime = () => { return { ...actions, ...connection, vspOptions }; }; -export const RealtimeProvider = ({ - children, - formId, - sheetName, - direction, - year, - userId, - isProject = false, -}) => { +export const RealtimeProvider = ({ children, formId, sheetName, direction, year, isProject = false }) => { // Формируем URL (кастомные секреты на фронте пока недоступны, делаем через REACT_APP_ROOT_PATH) const wsUrl = `${( - process.env.REACT_APP_API_URL || - process.env.REACT_APP_API_URL || - process.env.REACT_APP_ROOT_PATH || - "ws://localhost:8000" - ).replace(/^http/, "ws")}${ + process.env.REACT_APP_API_URL || process.env.REACT_APP_API_URL || process.env.REACT_APP_ROOT_PATH || 'ws://localhost:8000' + ).replace(/^http/, 'ws')}${ !isProject - ? `/api/v1/ws/form/${formId}/sheet/${sheetName}${direction ? `?direction=${direction}` : ""}` + ? `/api/v1/ws/form/${formId}/sheet/${sheetName}${direction ? `?direction=${direction}` : ''}` : `/api/v1/ws/projects/${formId}/report/${year}/${sheetName}` }`; @@ -104,73 +92,71 @@ export const RealtimeProvider = ({ if (lockedCellsStoreRef.current === null) { lockedCellsStoreRef.current = createLockedCellsStore(); } + const lockedCellsStore = lockedCellsStoreRef.current; const handleMessage = useCallback((data) => { // Обработка ошибок if (data.error) { console.log(data.error.message); - toast.error(`Server error:${data.error.message}` || ""); + toast.error(`Server error:${data.error.message}` || ''); onErrorRef.current?.(data.error); return; } switch (data.event) { - case "cell_edit_start": - console.log("Cell edit started:", data); + case 'cell_edit_start': + console.log('Cell edit started:', data); if (onCellEditStartRef.current) { onCellEditStartRef.current(data); } break; - case "cell_edit_end": - console.log("Cell edit ended:", data); + case 'cell_edit_end': + console.log('Cell edit ended:', data); if (onCellEditEndRef.current) { onCellEditEndRef.current(data); } break; - case "cell_updated": + case 'cell_updated': if (data.result && onCellUpdateRef.current) { - const updatedCells = Array.isArray(data.result) - ? data.result - : [data.result]; + const updatedCells = Array.isArray(data.result) ? data.result : [data.result]; onCellUpdateRef.current(updatedCells); } break; - case "row_added": + case 'row_added': if (data.result && onRowAddRef.current) { onRowAddRef.current(data); } break; - case "row_deleted": + case 'row_deleted': if (data.result && onRowDeleteRef.current) { onRowDeleteRef.current(data); } break; - case "program_added": - console.log("Program added:", data); + case 'program_added': + console.log('Program added:', data); if (data.result && onProgramAddRef.current) { onProgramAddRef.current(data); } break; - case "project_added": - console.log("Project added:", data); + case 'project_added': + console.log('Project added:', data); if (data.result && onProjectAddRef.current) { onProjectAddRef.current(data); } break; default: - console.log("Unknown event:", data.event); + console.log('Unknown event:', data.event); } }, []); - const { isConnectedRef, isConnected, sendMessage, disconnect, lastError } = - useWebSocket(wsUrl, handleMessage); + const { isConnectedRef, isConnected, sendMessage } = useWebSocket(wsUrl, handleMessage); const onCellUpdateRef = useRef(null); const onRowAddRef = useRef(null); @@ -182,21 +168,20 @@ export const RealtimeProvider = ({ const onCellEditStartRef = useRef(null); const onCellEditEndRef = useRef(null); const authAttemptedRef = useRef(false); - const reconnectTimerRef = useRef(null); // Функция для получения токена из localStorage const getAccessToken = useCallback(() => { try { - const token = localStorage.getItem("access_token"); + const token = localStorage.getItem('access_token'); if (!token) { - console.warn("No access token found in localStorage"); + console.warn('No access token found in localStorage'); return null; } return token; } catch (error) { - console.error("Error reading token from localStorage:", error); + console.error('Error reading token from localStorage:', error); return null; } }, []); @@ -204,13 +189,13 @@ export const RealtimeProvider = ({ const sendLogin = useCallback(() => { const token = getAccessToken(); if (!token) { - console.error("Cannot login: no token available"); - onErrorRef.current?.("Токен авторизации не найден"); + console.error('Cannot login: no token available'); + onErrorRef.current?.('Токен авторизации не найден'); return false; } const loginMessage = { - event: "user_login", + event: 'user_login', data: { token }, }; @@ -220,10 +205,11 @@ export const RealtimeProvider = ({ useEffect(() => { if (isConnected && !authAttemptedRef.current) { authAttemptedRef.current = true; - console.log("WebSocket connected, sending login..."); - setTimeout(() => { + console.log('WebSocket connected, sending login...'); + const timeoutId = setTimeout(() => { sendLogin(); }, 100); + return () => clearTimeout(timeoutId); } }, [isConnected, sendLogin]); @@ -233,27 +219,29 @@ export const RealtimeProvider = ({ } }, [isConnected]); - const addCommonField = (message) => { - if (isProject) { - message.report_type = sheetName; - message.project_id = formId; - message.year = year; - } else { - message.sheet = sheetName; - message.form_id = formId; - message.direction = direction; - } - return message; - }; + const addCommonField = useCallback( + (message) => { + if (isProject) { + message.report_type = sheetName; + message.project_id = formId; + message.year = year; + } else { + message.sheet = sheetName; + message.form_id = formId; + message.direction = direction; + } + return message; + }, + [direction, formId, isProject, sheetName, year], + ); // Функция для начала редактирования ячейки const startEditing = useCallback( - async (row, column) => { + (row, column) => { const columnId = column.id; - const rowId = row.id; - if (!isConnectedRef) { - onErrorRef.current?.("WebSocket не подключен"); + if (!isConnectedRef.current) { + onErrorRef.current?.('WebSocket не подключен'); return false; } @@ -261,7 +249,7 @@ export const RealtimeProvider = ({ const colId = columnId.slice(5); // убираем префикс "data." const message = { - event: "cell_edit_start", + event: 'cell_edit_start', data: { line_id: lineId, column: colId, @@ -275,17 +263,16 @@ export const RealtimeProvider = ({ return true; }, - [isConnected, sendMessage], + [isConnectedRef, sendMessage], ); // Функция для завершения редактирования ячейки const endEditing = useCallback( - async (row, column) => { + (row, column) => { const columnId = column.id; - const rowId = row.id; - if (!isConnectedRef) { - onErrorRef.current?.("WebSocket не подключен"); + if (!isConnectedRef.current) { + onErrorRef.current?.('WebSocket не подключен'); return false; } @@ -293,7 +280,7 @@ export const RealtimeProvider = ({ const colId = columnId.slice(5); const message = { - event: "cell_edit_end", + event: 'cell_edit_end', data: { line_id: lineId, column: colId, @@ -307,22 +294,21 @@ export const RealtimeProvider = ({ return true; }, - [isConnected, sendMessage], + [isConnectedRef, sendMessage], ); const updateCell = useCallback( - async (row, column, value) => { + (row, column, value) => { const columnId = column.id; - const rowId = row.id; - if (!isConnectedRef) { - onErrorRef.current?.("WebSocket не подключен"); + if (!isConnectedRef.current) { + onErrorRef.current?.('WebSocket не подключен'); return false; } const lineId = getRowId(row); const colId = columnId.slice(5); const message = { - event: "cell_updated", + event: 'cell_updated', data: { line_id: lineId, line_id_code: row.id, @@ -338,79 +324,79 @@ export const RealtimeProvider = ({ return true; }, - [isConnected, sendMessage], + [isConnectedRef, sendMessage], ); const addRow = useCallback( - async (rowData) => { - if (!isConnectedRef) { - onErrorRef.current?.("Нет подключения или авторизации"); + (rowData) => { + if (!isConnectedRef.current) { + onErrorRef.current?.('Нет подключения или авторизации'); return false; } const message = { - event: "row_added", + event: 'row_added', data: rowData, }; addCommonField(message); return sendMessage(message); }, - [isConnected, sendMessage, sheetName, formId, direction], + [addCommonField, isConnectedRef, sendMessage], ); const addProject = useCallback( - async (rowData) => { - if (!isConnectedRef) { - onErrorRef.current?.("Нет подключения или авторизации"); + (rowData) => { + if (!isConnectedRef.current) { + onErrorRef.current?.('Нет подключения или авторизации'); return false; } const message = { - event: "project_added", + event: 'project_added', data: rowData, }; addCommonField(message); return sendMessage(message); }, - [isConnected, sendMessage, sheetName, formId, direction], + [addCommonField, isConnectedRef, sendMessage], ); const addProgram = useCallback( - async (rowData) => { - if (!isConnectedRef) { - onErrorRef.current?.("Нет подключения или авторизации"); + (rowData) => { + if (!isConnectedRef.current) { + onErrorRef.current?.('Нет подключения или авторизации'); return false; } const message = { - event: "program_added", + event: 'program_added', data: rowData, }; addCommonField(message); return sendMessage(message); }, - [isConnected, sendMessage, sheetName, formId, direction], + [addCommonField, isConnectedRef, sendMessage], ); const deleteRow = useCallback( - async (rowId) => { - if (!isConnectedRef) { - onErrorRef.current?.("Нет подключения или авторизации"); + (rowId) => { + if (!isConnectedRef.current) { + onErrorRef.current?.('Нет подключения или авторизации'); return false; } const message = { - event: "row_deleted", + event: 'row_deleted', data: { row_id: rowId }, }; addCommonField(message); return sendMessage(message); }, - [isConnected, sendMessage, sheetName, formId, direction], + [addCommonField, isConnectedRef, sendMessage], ); const subscribeToCellUpdates = useCallback((callback) => { @@ -477,42 +463,65 @@ export const RealtimeProvider = ({ }, []); const retryLogin = useCallback(() => { - if (isConnected) { + if (isConnectedRef.current) { authAttemptedRef.current = false; sendLogin(); } - }, [isConnected, sendLogin]); + }, [isConnectedRef, sendLogin]); + + const actions = useMemo( + () => ({ + startEditing, + endEditing, + updateCell, + addRow, + addProgram, + addProject, + deleteRow, + subscribeToRowAdds, + subscribeToRowDeletes, + subscribeToProgramAdds, + subscribeToProjectAdds, + subscribeToErrors, + subscribeToAuthSuccess, + subscribeToCellEditStart, + subscribeToCellEditEnd, + subscribeToCellUpdates, + retryLogin, + setLockedCells: lockedCellsStore.setCells, + setVspOptions, + }), + [ + addProgram, + addProject, + addRow, + deleteRow, + endEditing, + lockedCellsStore, + retryLogin, + startEditing, + subscribeToAuthSuccess, + subscribeToCellEditEnd, + subscribeToCellEditStart, + subscribeToCellUpdates, + subscribeToErrors, + subscribeToProgramAdds, + subscribeToProjectAdds, + subscribeToRowAdds, + subscribeToRowDeletes, + updateCell, + ], + ); + + const connection = useMemo(() => ({ isConnected }), [isConnected]); return ( - releaseAllLocks(userId), - }} - > - {children} - + + + + {children} + + + ); }; diff --git a/web/src/components/RealtimeTable/hooks/useColumnSettings.js b/web/src/components/RealtimeTable/hooks/useColumnSettings.js index a6c6741..fbbe006 100644 --- a/web/src/components/RealtimeTable/hooks/useColumnSettings.js +++ b/web/src/components/RealtimeTable/hooks/useColumnSettings.js @@ -33,7 +33,10 @@ export const useColumnSettings = (tableKey) => { }, [tableKey]); const handlePinColumn = useCallback((colId) => { - setColumnPinning((prev) => ({ ...prev, left: [...prev.left, colId] })); + setColumnPinning((prev) => { + if (!colId || prev.left.includes(colId)) return prev; + return { ...prev, left: [...prev.left, colId] }; + }); }, []); const handleUnpinColumn = useCallback((colId) => { @@ -63,11 +66,18 @@ export const useColumnSettings = (tableKey) => { return acc; }, {}); - setColumnVisibility((prev) => ({ ...prev, ...leafCols })); + setColumnVisibility((prev) => { + const hasChanges = Object.entries(leafCols).some(([columnId, isVisible]) => prev[columnId] !== isVisible); + return hasChanges ? { ...prev, ...leafCols } : prev; + }); }, []); const handleSetColumnsVisibility = useCallback((visibilityByColumnId) => { - setColumnVisibility((prev) => ({ ...prev, ...visibilityByColumnId })); + setColumnVisibility((prev) => { + const entries = Object.entries(visibilityByColumnId); + const hasChanges = entries.some(([columnId, isVisible]) => prev[columnId] !== isVisible); + return hasChanges ? { ...prev, ...visibilityByColumnId } : prev; + }); }, []); // Опционально: функция для сброса всех настроек diff --git a/web/src/components/RealtimeTable/hooks/useRealtimeData.js b/web/src/components/RealtimeTable/hooks/useRealtimeData.js index 0ba5217..04816b9 100644 --- a/web/src/components/RealtimeTable/hooks/useRealtimeData.js +++ b/web/src/components/RealtimeTable/hooks/useRealtimeData.js @@ -1,29 +1,54 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import { toast } from "react-toastify"; -import { FormsSheetApi } from "../../../api/form_sheet"; -import { ProjectsApi } from "../../../api/projects"; -import { useRealtime } from "../contexts/RealtimeContext"; -import { - addRootRow, - deleteRow, - insertCell, - insertProjectRow, - updateCells, -} from "../utils/cellUtils"; -import { getParentId, isVspNewRow } from "../utils/rowUtils"; +import { useEffect, useState } from 'react'; +import { toast } from 'react-toastify'; +import { FormsSheetApi } from '../../../api/form_sheet'; +import { ProjectsApi } from '../../../api/projects'; +import { useRealtimeActions, useRealtimeConnection } from '../contexts/RealtimeContext'; +import { addRootRow, deleteRow, insertCell, insertProjectRow, updateCells } from '../utils/cellUtils'; +import { getParentId, isVspNewRow } from '../utils/rowUtils'; + +const ROW_TYPE_LEVELS = { + ROOT: 0, + GROUP: 1, + ITEM: 2, + SUB_ITEM: 3, + INPUT: 4, +}; + +const buildHierarchy = (items) => { + const result = []; + const stack = []; + + for (const item of items) { + const currentLevel = ROW_TYPE_LEVELS[item.row_type] ?? 999; + + while (stack.length > 0 && (ROW_TYPE_LEVELS[stack.at(-1).row_type] ?? 999) >= currentLevel) { + stack.pop(); + } + + const parent = stack.at(-1); + const newNode = { ...item, subRows: [] }; + + if (parent) { + parent.subRows.push(newNode); + } else { + result.push(newNode); + } + + if (item.row_type !== 'INPUT') { + stack.push(newNode); + } + } + + return result; +}; const useRealtimeData = (formId, sheetName, direction, formType, year) => { const [data, setData] = useState([]); const [columnsCurStage, setColumnsCurStage] = useState([]); - const [isConnected, setIsConnected] = useState(false); - const [error, setError] = useState(null); const [isLoading, setIsLoading] = useState(true); const [editingCells, setEditingCells] = useState({}); - const dataRef = useRef(data); const { - isConnected: wsConnected, - error: wsError, subscribeToCellUpdates, subscribeToRowAdds, subscribeToProgramAdds, @@ -33,62 +58,19 @@ const useRealtimeData = (formId, sheetName, direction, formType, year) => { subscribeToCellEditStart, subscribeToCellEditEnd, setLockedCells, - } = useRealtime(); - - const formatedToSubRows = useCallback((depth, oldData) => { - if (depth === 0) return oldData; - - const dataFiltered = oldData.filter((d) => d.depth < depth); - - for (let i = 0; i < dataFiltered.length; i++) { - const curRow = dataFiltered[i]; - - // Находим следующий элемент на том же или более высоком уровне - let nextRow = null; - for (let j = i + 1; j < dataFiltered.length; j++) { - if (dataFiltered[j].depth <= curRow.depth) { - nextRow = dataFiltered[j]; - break; - } - } - - // Находим всех потомков для текущей строки - const subRows = oldData.filter((d) => { - // Потомок должен иметь глубину больше чем у родителя - if (d.depth <= curRow.depth) return false; - - // Потомок должен идти после родителя по sort_order - if (d.sort_order <= curRow.sort_order) return false; - - // Если есть следующий элемент на том же уровне - потомок должен быть до него - if (nextRow && d.sort_order >= nextRow.sort_order) return false; - - return true; - }); - - if (subRows.length > 0) { - curRow.subRows = subRows; - } - } - - // Рекурсивно обрабатываем следующий уровень - // Находим максимальную глубину в текущем dataFiltered - const maxDepth = Math.max(...dataFiltered.map((d) => d.depth)); - if (maxDepth > 0) { - return formatedToSubRows(maxDepth, dataFiltered); - } - - return dataFiltered; - }, []); + } = useRealtimeActions(); + const { isConnected: wsConnected } = useRealtimeConnection(); // Загрузка начальных данных useEffect(() => { + let isCancelled = false; + const loadInitialData = async () => { try { setIsLoading(true); let res; - if (formType === "PROJECT") { + if (formType === 'PROJECT') { res = await ProjectsApi.getTableData(formId, sheetName, year); } else if (direction !== null) { res = await FormsSheetApi.get(formId, sheetName, { @@ -97,15 +79,12 @@ const useRealtimeData = (formId, sheetName, direction, formType, year) => { } else { res = await FormsSheetApi.get(formId, sheetName); } + if (isCancelled) return; + const data = res.result.filter((d) => d.depth > -1); const curStageColumns = res.result.filter((d) => d.depth === -1)[0]; - if (curStageColumns) { - const cols = []; - for (const col of curStageColumns.data.editable) { - cols.push(`data.${col.column}`); - } - setColumnsCurStage(cols); - } + const stageColumns = (curStageColumns?.data?.editable || []).map((column) => `data.${column.column}`); + setColumnsCurStage(stageColumns); if (!data || data.length === 0) { setData([]); @@ -115,74 +94,18 @@ const useRealtimeData = (formId, sheetName, direction, formType, year) => { const hierarchicalData = buildHierarchy(data); setData(hierarchicalData); } catch (err) { - console.error("Failed to load initial data:", err); - setError(err.message); + if (!isCancelled) console.error('Failed to load initial data:', err); } finally { - setIsLoading(false); + if (!isCancelled) setIsLoading(false); } }; loadInitialData(); - }, [formId, sheetName]); - const buildHierarchy = (items) => { - const result = []; - const stack = []; - - const getTypeLevel = (type) => { - const levels = { - ROOT: 0, - GROUP: 1, - ITEM: 2, - SUB_ITEM: 3, - INPUT: 4, - }; - return levels[type] ?? 999; + return () => { + isCancelled = true; }; - - for (const item of items) { - const currentLevel = getTypeLevel(item.row_type); - - while ( - stack.length > 0 && - getTypeLevel(stack[stack.length - 1].row_type) >= currentLevel - ) { - stack.pop(); - } - - const parent = stack.length > 0 ? stack[stack.length - 1] : null; - - const newNode = { ...item, subRows: [] }; - - if (parent) { - if (!parent.subRows) { - parent.subRows = []; - } - parent.subRows.push(newNode); - } else { - result.push(newNode); - } - - // Добавляем текущий узел в стек, если он может иметь детей - if (item.row_type !== "INPUT") { - stack.push(newNode); - } - } - - return result; - }; - - useEffect(() => { - setIsConnected(wsConnected); - }, [wsConnected]); - - useEffect(() => { - setError(wsError); - }, [wsError]); - - useEffect(() => { - dataRef.current = data; - }, [data]); + }, [direction, formId, formType, sheetName, year]); // Подписка на обновления WebSocket useEffect(() => { @@ -213,12 +136,12 @@ const useRealtimeData = (formId, sheetName, direction, formType, year) => { [[], null], ); if (newRow === null) { - toast.error("Ошибка получения данных"); + toast.error('Ошибка получения данных'); return; } setData((prevData) => { let data = structuredClone(prevData); - if (prevData[0]?.row_type !== "ROOT" && updates[0][0] === 'ROOT') { + if (prevData[0]?.row_type !== 'ROOT' && updates[0][0] === 'ROOT') { const newRowRoot = updates[0]; data = addRootRow(data, newRowRoot); } @@ -233,7 +156,7 @@ const useRealtimeData = (formId, sheetName, direction, formType, year) => { const program_id = data.data.program_id; const [updatedCells, newRow] = updates.reduce( (res, row) => { - if (row[0] === "ITEM") { + if (row[0] === 'ITEM') { res[1] = row; } else { res[0].push(row); @@ -243,7 +166,7 @@ const useRealtimeData = (formId, sheetName, direction, formType, year) => { [[], null], ); if (newRow === null) { - toast.error("Ошибка получения данных"); + toast.error('Ошибка получения данных'); return; } setData((prevData) => { @@ -256,7 +179,7 @@ const useRealtimeData = (formId, sheetName, direction, formType, year) => { const unsubscribeProgramAdds = subscribeToProgramAdds((msg) => { let newRow = msg.result[1][0]; setData((prevData) => { - const hasRoot = prevData.length > 0 && prevData[0].row_type === "ROOT"; + const hasRoot = prevData.length > 0 && prevData[0].row_type === 'ROOT'; if (hasRoot) { newRow = msg.result[1][1]; } @@ -291,7 +214,7 @@ const useRealtimeData = (formId, sheetName, direction, formType, year) => { // Обработчик начала редактирования ячейки const unsubscribeCellEditStart = subscribeToCellEditStart((data) => { const dataCell = data.data; - dataCell.column = "data." + dataCell.column; + dataCell.column = `data.${dataCell.column}`; if (data.is_self) { setEditingCells(dataCell); return; @@ -318,9 +241,7 @@ const useRealtimeData = (formId, sheetName, direction, formType, year) => { }); const unsubscribeErrors = subscribeToErrors((err) => { - console.error("WebSocket error:", err); - setError(err); - setTimeout(() => setError(null), 5000); + console.error('WebSocket error:', err); }); return () => { @@ -343,35 +264,12 @@ const useRealtimeData = (formId, sheetName, direction, formType, year) => { subscribeToCellEditEnd, subscribeToProgramAdds, subscribeToProjectAdds, - formatedToSubRows, ]); - const updateData = useCallback( - (newData) => { - if (Array.isArray(newData) && !newData[0]?.subRows) { - const maxDepth = newData.reduce( - (prev, current) => { - return (prev.depth || 0) > (current.depth || 0) ? prev : current; - }, - { depth: 0 }, - ).depth; - - const formattedData = formatedToSubRows(maxDepth, newData); - setData(formattedData); - } else { - setData(newData); - } - }, - [formatedToSubRows], - ); - return { data, - setData, columnsCurStage, - isConnected, isLoading, - error, editingCells, }; }; diff --git a/web/src/components/RealtimeTable/hooks/useValidationRules.js b/web/src/components/RealtimeTable/hooks/useValidationRules.js index c3be814..b234f12 100644 --- a/web/src/components/RealtimeTable/hooks/useValidationRules.js +++ b/web/src/components/RealtimeTable/hooks/useValidationRules.js @@ -1,32 +1,49 @@ -import { useCallback, useMemo } from 'react'; +import { useCallback, useMemo, useRef } from 'react'; import { validationRules } from '../constants/validation'; import { buildColumnHeaderPaths, collectInvalidErrors, isValidationRuleEnabled } from '../utils/validationUtils'; -export const useValidationRules = (data = [], columns = [], formType, sheetName) => { - const headerPaths = useMemo(() => buildColumnHeaderPaths(columns), [columns]); +const EMPTY_ARRAY = []; + +export const useValidationRules = (data, columns, formType, sheetName) => { + const safeData = data || EMPTY_ARRAY; + const safeColumns = columns || EMPTY_ARRAY; + const stableErrorsRef = useRef([]); + const headerPaths = useMemo(() => buildColumnHeaderPaths(safeColumns), [safeColumns]); const activeRules = useMemo( () => validationRules.filter((rule) => isValidationRuleEnabled(rule, { formType, sheetName })), [formType, sheetName], ); - const errors = useMemo(() => { + const calculatedErrors = useMemo(() => { const result = []; const seen = new Set(); for (const rule of activeRules) { - collectInvalidErrors(data, rule, headerPaths, result, seen); + collectInvalidErrors(safeData, rule, headerPaths, result, seen); } return result; - }, [activeRules, data, headerPaths]); + }, [activeRules, headerPaths, safeData]); + + const previousErrors = stableErrorsRef.current; + const errorsAreEqual = + previousErrors.length === calculatedErrors.length && + previousErrors.every( + (error, index) => + error.id === calculatedErrors[index].id && + error.columnId === calculatedErrors[index].columnId && + error.message === calculatedErrors[index].message, + ); + if (!errorsAreEqual) { + stableErrorsRef.current = calculatedErrors; + } + const errors = stableErrorsRef.current; const isCellInvalid = useCallback( (columnId, value, row) => activeRules.some( (rule) => - rule.columnKeys.includes(columnId) && - (!rule.appliesToRow || rule.appliesToRow(row)) && - rule.isInvalid(value, row, columnId), + rule.columnKeys.includes(columnId) && (!rule.appliesToRow || rule.appliesToRow(row)) && rule.isInvalid(value, row, columnId), ), [activeRules], ); diff --git a/web/src/components/RealtimeTable/tableColumns.jsx b/web/src/components/RealtimeTable/tableColumns.jsx index fb39e49..422f202 100644 --- a/web/src/components/RealtimeTable/tableColumns.jsx +++ b/web/src/components/RealtimeTable/tableColumns.jsx @@ -1,31 +1,19 @@ import React, { useEffect, useRef, useState, useMemo, useCallback } from 'react'; import { createPortal } from 'react-dom'; import { useParams } from 'react-router'; -import { useRealtime } from './contexts/RealtimeContext'; +import { useVspOptions } from './contexts/RealtimeContext'; -import { sectionCodeColor } from './constants/columnConfig'; -import { blueColumn, greenColumn } from './constants/columnColors'; import { ROLES_NAME_ID } from '../../constants/constants'; +import { blueColumn } from './constants/columnColors'; +import { sectionCodeColor } from './constants/columnConfig'; import VspDropdownEditCell from './Cell/EditCell/VspDropdownEditCell'; -const EditCellPortal = React.memo(({ - cell, - table, - EditCell, - onSaveStart, - onSaveEnd, - onError, - isEditable -}) => { +const EditCellPortal = React.memo(({ cell, table, EditCell, onError, isEditable }) => { const { formId, formType, sheetName, direction } = useParams(); const tableKey = `${formId}_${formType}_${sheetName}_${direction}`; const ref = useRef(null); const [refTbody, setRefTbody] = useState(null); - const [isSaving, setIsSaving] = useState(false); - - const { endEditing: contextEndEditing } = useRealtime(); - useEffect(() => { if (ref.current) { const tbodyRef = ref.current.offsetParent?.offsetParent?.offsetParent; @@ -35,54 +23,50 @@ const EditCellPortal = React.memo(({ } }, []); - const handleChange = useCallback((val) => { - table.setEditingCell(null); - try { - if (table.options.meta?.updateCell) { - const success = table.options.meta.updateCell( - cell.row, - cell.column, - val - ); - if (!success) { - console.error('Failed to update cell via WebSocket'); - onError?.('Не удалось сохранить изменение'); + const handleChange = useCallback( + (val) => { + table.setEditingCell(null); + try { + if (table.options.meta?.updateCell) { + const success = table.options.meta.updateCell(cell.row, cell.column, val); + if (!success) { + console.error('Failed to update cell via WebSocket'); + onError?.('Не удалось сохранить изменение'); + } } + } catch (error) { + console.error('Error updating cell:', error); + onError?.(error.message); } - } catch (error) { - console.error('Error updating cell:', error); - onError?.(error.message); - } - }, [table, cell, onError]); + }, + [table, cell, onError], + ); - const editCellProps = useMemo(() => ({ - refCell: ref, - cell, - value: cell.getValue(), - disabled: !isEditable, - onChange: handleChange, - tableId: tableKey, - table, - cellId: `${cell.row.id}_${cell.column.id}`, - }), [cell, isEditable, handleChange, tableKey, table]); + const editCellProps = useMemo( + () => ({ + refCell: ref, + cell, + value: cell.getValue(), + disabled: !isEditable, + onChange: handleChange, + tableId: tableKey, + table, + cellId: `${cell.row.id}_${cell.column.id}`, + }), + [cell, isEditable, handleChange, tableKey, table], + ); const editType = cell.column.columnDef?.editType; const portalContent = useMemo(() => { - if (!refTbody || isSaving) return null; + if (!refTbody) return null; if (editType === 'vsp_dropdown') { - return createPortal( - , - refTbody - ); + return createPortal(, refTbody); } - return createPortal( - , - refTbody - ); - }, [refTbody, isSaving, editCellProps, EditCell, editType]); + return createPortal(, refTbody); + }, [refTbody, editCellProps, EditCell, editType]); return ( <> @@ -94,8 +78,7 @@ const EditCellPortal = React.memo(({ function getColorCell(colors, column, row) { const rowType = row?.original?.row_type || row?.row_type; - const sectionCodeValue = - row?.original?.data?.section_code ?? row?.original?.data?.header?.section_code; + const sectionCodeValue = row?.original?.data?.section_code ?? row?.original?.data?.header?.section_code; if (sectionCodeValue !== null && sectionCodeValue !== undefined && sectionCodeValue !== '') { const sectionCode = String(sectionCodeValue)[0]; @@ -105,8 +88,7 @@ function getColorCell(colors, column, row) { return colors[column.id]?.color_type?.[rowType]; } -const UPPERCASE_MONTH_PATTERN = - /ЯНВАРЬ|ФЕВРАЛЬ|МАРТ|АПРЕЛЬ|МАЙ|ИЮНЬ|ИЮЛЬ|АВГУСТ|СЕНТЯБРЬ|ОКТЯБРЬ|НОЯБРЬ|ДЕКАБРЬ/g; +const UPPERCASE_MONTH_PATTERN = /ЯНВАРЬ|ФЕВРАЛЬ|МАРТ|АПРЕЛЬ|МАЙ|ИЮНЬ|ИЮЛЬ|АВГУСТ|СЕНТЯБРЬ|ОКТЯБРЬ|НОЯБРЬ|ДЕКАБРЬ/g; const normalizeMonthHeader = (header) => { if (typeof header !== 'string') return header; @@ -119,12 +101,17 @@ const normalizeMonthHeader = (header) => { }); }; +const CellWithVspOptions = React.memo(({ CellComponent, cellProps }) => { + const vspOptions = useVspOptions(); + return ; +}); + export const getTableColumns = ({ Cell, EditCell, columnsConfig, columnsCurStage, - user, + userRoleId, onCellUpdateError, onCellNumberClick, isCellInvalid, @@ -137,11 +124,11 @@ export const getTableColumns = ({ const isEditable = (row, column) => { if (row.original?.row_type !== 'INPUT') return false; - if (user.role_id === ROLES_NAME_ID.admin) return true; + if (userRoleId === ROLES_NAME_ID.admin) return true; const keyCol = column.id; if (columnsCurStage.includes(keyCol)) return true; return false; - } + }; const getCachedCellProps = (row, column, table) => { const key = `${row.id}_${column.id}`; @@ -149,8 +136,6 @@ export const getTableColumns = ({ const isInvalid = isCellInvalid?.(column.id, value, row.original) || false; const isEditableCell = isEditable(row, column); const backgroundColor = getColorCell(columnColors, column, row); - const isUpdating = table.options.meta?.updatingCells?.[key]; - if (!cellPropsCache.has(row)) { cellPropsCache.set(row, {}); } @@ -159,19 +144,21 @@ export const getTableColumns = ({ if (rowCache[key]) { const cached = rowCache[key]; const currentGlobalFilter = table.getState().globalFilter; - const currentColumnFilter = table.getState().columnFilters?.find(f => f.id === column.id)?.value; + const currentColumnFilter = table.getState().columnFilters?.find((f) => f.id === column.id)?.value; - if (cached.globalFilter === currentGlobalFilter && - cached.columnFilter === currentColumnFilter && - cached.value === value && - cached.isInvalid === isInvalid && - cached.backgroundColor === backgroundColor) { - return cached; - } + if ( + cached.globalFilter === currentGlobalFilter && + cached.columnFilter === currentColumnFilter && + cached.value === value && + cached.isInvalid === isInvalid && + cached.backgroundColor === backgroundColor + ) { + return cached; + } } const globalFilter = table.getState().globalFilter; - const columnFilter = table.getState().columnFilters?.find(f => f.id === column.id)?.value; + const columnFilter = table.getState().columnFilters?.find((f) => f.id === column.id)?.value; const props = { globalFilter, @@ -187,7 +174,7 @@ export const getTableColumns = ({ return props; }; - const getCachedEditProps = (row, column, table) => { + const getCachedEditProps = (row, column) => { const key = `${row.id}_${column.id}`; if (!editPropsCache.has(row)) { @@ -211,34 +198,31 @@ export const getTableColumns = ({ const processColumns = (columns) => { for (const col of columns) { col.header = normalizeMonthHeader(col.header); + const usesVspOptions = col.editType === 'vsp_dropdown'; col.Cell = ({ cell, table, column, row }) => { - const { vspOptions } = useRealtime(); const props = getCachedCellProps(row, column, table); - const cellProps = useMemo(() => ({ - row, - column, - cell, - vspOptions, - ...props, - }), [row, column, cell, props, vspOptions,]); + const cellProps = useMemo( + () => ({ + row, + column, + cell, + ...props, + }), + [row, column, cell, props], + ); - return ; + return usesVspOptions ? : ; }; col.Edit = ({ cell, table, row, column }) => { - const props = getCachedEditProps(row, column, table); + const props = getCachedEditProps(row, column); - const editComponent = useMemo(() => ( - - ), [cell, table, EditCell, onCellUpdateError, props.isEditable]); + const editComponent = useMemo( + () => , + [cell, table, EditCell, onCellUpdateError, props.isEditable], + ); return editComponent; }; @@ -251,8 +235,7 @@ export const getTableColumns = ({ processColumns(col.columns); } } - } - + }; processColumns(columns); @@ -265,7 +248,7 @@ export const getTableColumns = ({ const handleClick = useCallback(() => { onCellNumberClick(row.id); }, [row.id, onCellNumberClick]); - return ; + return ; }, }; diff --git a/web/src/components/common/CollapsibleTree.jsx b/web/src/components/common/CollapsibleTree.jsx index 2672bc4..afb3194 100644 --- a/web/src/components/common/CollapsibleTree.jsx +++ b/web/src/components/common/CollapsibleTree.jsx @@ -2,35 +2,38 @@ import { ArrowDropDown, ChevronRight, ExpandMore } from '@mui/icons-material'; import { CheckBox, CheckBoxOutlineBlank, IndeterminateCheckBox } from '@mui/icons-material'; import { Box, Checkbox, InputAdornment, Popover, TextField, Typography } from '@mui/material'; import { SimpleTreeView, TreeItem } from '@mui/x-tree-view'; -import { useEffect, useMemo, useState } from 'react'; +import { memo, useMemo, useState } from 'react'; import { filterColumnTree, getColumnTreeLeafKeys } from '../RealtimeTable/utils/columnUtils'; +const EMPTY_IDS = []; + const CollapsibleTree = ({ columnTree, - excludeColumnKeys = [], + excludeColumnKeys = EMPTY_IDS, placeholder = 'Скрыть столбцы', placeholderSelect = 'Скрыто столбцов: ', - selectedIds = [], + selectedIds = EMPTY_IDS, onSelectNode, + onSelectAllNodes, forStage = false, }) => { const [expandedItems, setExpandedItems] = useState([]); const [anchorEl, setAnchorEl] = useState(null); - const [selectedNodes, setSelectedNodes] = useState([]); - - useEffect(() => { - setSelectedNodes(selectedIds); - }, [selectedIds]); + const selectedNodes = selectedIds; const visibleColumnTree = useMemo(() => filterColumnTree(columnTree, excludeColumnKeys), [columnTree, excludeColumnKeys]); const visibleLeafKeys = useMemo(() => getColumnTreeLeafKeys(visibleColumnTree), [visibleColumnTree]); - const handleExpandedItemsChange = (event, itemIds) => { + const handleExpandedItemsChange = (_event, itemIds) => { setExpandedItems(itemIds); }; const handleSelectAll = (isSelected) => { + if (onSelectAllNodes) { + onSelectAllNodes(visibleLeafKeys, !isSelected); + return; + } for (const col of visibleColumnTree) { onSelectNode?.(col, !isSelected); } @@ -248,4 +251,4 @@ const CollapsibleTree = ({ ); }; -export default CollapsibleTree; +export default memo(CollapsibleTree); diff --git a/web/src/components/common/SearchComponent.jsx b/web/src/components/common/SearchComponent.jsx index 51c151d..21394a9 100644 --- a/web/src/components/common/SearchComponent.jsx +++ b/web/src/components/common/SearchComponent.jsx @@ -1,5 +1,5 @@ import { InputAdornment, TextField } from '@mui/material'; -import { useEffect, useRef, useState } from 'react'; +import { memo, useEffect, useRef, useState } from 'react'; import { Search } from './icons/icons'; function SearchComponent({ placeholder = 'Поиск...', value = '', onChange, debounceMs = 0, height = '2rem', sx: sxProp, ...rest }) { @@ -75,4 +75,4 @@ function SearchComponent({ placeholder = 'Поиск...', value = '', onChange, ); } -export default SearchComponent; +export default memo(SearchComponent); -- 2.47.2