This commit is contained in:
PotapovaA 2026-08-19 18:24:59 +03:00
parent e2760bb39a
commit 336e4123e5
4 changed files with 259 additions and 327 deletions

View File

@ -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);

View File

@ -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;
}
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 }) => {
<div style={tableWrapperStyle}>
<div style={tableScaleStyle}>
<div
style={{
position: 'relative',
width: '100%',
flex: 1,
minHeight: 0,
height: '100%',
}}
>
<div style={tableContentStyle}>
<MaterialReactTable table={table} />
<ColumnSelectionOverlay
@ -643,17 +656,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
/>
{isTableLoading && (
<div
style={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'rgba(255, 255, 255, 0.6)',
zIndex: 2,
}}
>
<div style={loadingOverlayStyle}>
<CircularProgress />
</div>
)}
@ -662,7 +665,11 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
createPortal(
<TableHead
table={table}
style={{ zIndex: (theme) => 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 }) => {
</div>
<SelectVspModal
isOpen={isOpenModalSelectVsp}
onClose={() => setIsOpenModalSelectVsp(false)}
onClose={closeSelectVspModal}
formId={formId}
onSelect={handleAddVspRow}
title="Выбор ВСП"
/>
<SelectExpenseItemModal
isOpen={isOpenModalSelectExpenseItem}
onClose={() => setIsOpenModalSelectExpenseItem(false)}
onClose={closeSelectExpenseItemModal}
formId={formId}
onSelect={handleAddExpenseItemRow}
title="Добавление строки"
sheet={sheetName}
/>
<CreateProgramModal
isOpen={isOpenModalCreateProgram}
onClose={() => setIsOpenModalCreateProgram(false)}
title={isProgramCreate ? "Создание программы" : "Создание проекта"}
onCreate={isProgramCreate ? handleAddProgramRow : handleAddProjectRow}
isOpen={createModalType !== null}
onClose={closeCreateModal}
title={createModalType === 'program' ? 'Создание программы' : 'Создание проекта'}
onCreate={createModalType === 'program' ? handleAddProgramRow : handleAddProjectRow}
/>
</>
);

View File

@ -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);
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}>
<Pin />
</IconButton>
</Tooltip>
@ -223,7 +215,7 @@ const SettingPanel = ({
</GroupByObject>
<Divider orientation='vertical' flexItem />
<GroupByObject title='Поиск'>
<SearchComponent onChange={onGlobalFilterChange} height='2.5rem' sx={{ height: '2.5rem' }} />
<SearchComponent onChange={onGlobalFilterChange} height='2.5rem' sx={searchSx} />
<Tooltip title='Поиск по колонкам'>
<IconButton
variant='outlined'
@ -243,7 +235,7 @@ const SettingPanel = ({
onClick={handleExport}
disabled={!formId || !sheetName || isExporting}
text={isExporting ? 'Экспорт...' : 'Экспорт'}
sx={{ height: '2.5rem', minHeight: '2.5rem' }}
sx={exportButtonSx}
/>
</GroupByObject>
</Stack>
@ -253,4 +245,4 @@ const SettingPanel = ({
);
};
export default SettingPanel;
export default memo(SettingPanel);

View File

@ -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();
return {
isPinned: Boolean(pinningPosition),
left: pinningPosition === 'left' ? column.getStart('left') : undefined,
right: pinningPosition === 'right' ? column.getAfter('right') : undefined,
};
// Ширина закреплённых дочерних колонок группового заголовка
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;
};
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) => (
<div style={{ position: 'relative' }}>
return (
<div
style={{
position: isPinned ? 'sticky' : 'relative',
left: leftOffset,
right: rightOffset,
zIndex: isPinned ? 21 : 'auto',
width: `${headerSize}px`,
minWidth: `${headerSize}px`,
maxWidth: `${headerSize}px`,
flex: '0 0 auto',
}}>
<div
onClick={() => 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 }) => {
<div
style={{
textAlign: 'center',
position: 'sticky',
left: textLeft,
right: 0,
position: 'relative',
paddingRight: '0.5rem',
paddingLeft: '0.5rem',
backgroundColor: backgroundColor,
@ -245,26 +180,6 @@ const HeaderCell = ({ header, table, onClick, onChangeWidth }) => {
</div>
</div>
);
if (splitGroup) {
return (
<>
<div style={{ position: 'sticky', left: leftOffset, zIndex: 21, flexShrink: 0 }}>{renderCell(pinnedBandWidth, '0')}</div>
<div style={{ position: 'relative', flexShrink: 0 }}>{renderCell(headerSize - pinnedBandWidth, getTextLeftOffset(true))}</div>
</>
);
}
return (
<div
style={{
position: isPinned ? 'sticky' : 'relative',
left: isPinned ? leftOffset : 'auto',
zIndex: isPinned ? 21 : 'auto',
}}>
{renderCell(headerSize, getTextLeftOffset())}
</div>
);
};
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 <div></div>;
}
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 (
<>
<div
@ -428,17 +350,26 @@ export const TableHead = ({ table, selectedColumnId, onColumnSelect, onChangeWid
{table.getState().showColumnFilters && <FilterRow table={table} />}
{/* Существующие строки заголовков */}
{table.getHeaderGroups().map((headerGroup) => (
<div key={headerGroup.id} style={{ display: 'flex' }}>
{headerGroup.headers.map((header) => (
<React.Fragment key={header.id}>
<HeaderCell header={header} table={table} onClick={handleHeaderClick} onChangeWidth={onChangeWidth} />
</React.Fragment>
))}
{/* Pinned и обычные заголовки строятся отдельными группами. */}
{Array.from({ length: headerRowsCount }, (_, rowIndex) => (
<div key={`header-row-${rowIndex}`} style={{ display: 'flex' }}>
{headerSections.flatMap(({ groups, pinningPosition }) =>
(groups[rowIndex]?.headers || []).map((header) => (
<HeaderCell
key={`${pinningPosition || 'center'}-${header.id}`}
header={header}
table={table}
pinningPosition={pinningPosition}
onClick={handleHeaderClick}
onChangeWidth={onChangeWidth}
/>
)),
)}
</div>
))}
</div>
</>
);
};
export const TableHead = React.memo(TableHeadComponent);