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'; import { createPortal } from 'react-dom';
const toLocalCoord = (value, scale) => value / scale; const toLocalCoord = (value, scale) => value / scale;
@ -54,7 +54,7 @@ const getOverlayRect = (container, overlayParent, selectedColumnId, columnPinnin
return { top, left, width, height }; 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 [rect, setRect] = useState(null);
const [portalTarget, setPortalTarget] = useState(null); const [portalTarget, setPortalTarget] = useState(null);
@ -135,3 +135,5 @@ export const ColumnSelectionOverlay = ({ selectedColumnId, containerRef, scale =
portalTarget, portalTarget,
); );
}; };
export const ColumnSelectionOverlay = memo(ColumnSelectionOverlayComponent);

View File

@ -29,7 +29,6 @@ import { additionExpenseRowTable, additionVspRowTable } from './constants/adding
import { SelectVspModal } from './Modals/SelectVspModal'; import { SelectVspModal } from './Modals/SelectVspModal';
import { SelectExpenseItemModal } from './Modals/SelectExpenseItemModal'; import { SelectExpenseItemModal } from './Modals/SelectExpenseItemModal';
import { getRowId } from './utils/rowUtils'; import { getRowId } from './utils/rowUtils';
import { FORM_TYPE_OPTIONS } from '../../constants/constants';
import { debounce } from '@mui/material'; import { debounce } from '@mui/material';
import { CreateProgramModal } from './Modals/CreateProgramProjectModal'; import { CreateProgramModal } from './Modals/CreateProgramProjectModal';
@ -37,15 +36,69 @@ import { FormsNoCanAddRow } from './constants/formConfig';
import { DictVspApi } from '../../api/dict-vsp'; import { DictVspApi } from '../../api/dict-vsp';
import { useAuth } from '../../app/context/AuthProvider'; 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 RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
const { user } = useAuth(); 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 [globalFilter, setGlobalFilter] = useState('');
const [showColumnFilters, setShowColumnFilters] = useState(false); const [showColumnFilters, setShowColumnFilters] = useState(false);
const [selectedColumnId, setSelectedColumnId] = useState(); const [selectedColumnId, setSelectedColumnId] = useState();
const [rowSelection, setRowSelection] = useState({}); const [rowSelection, setRowSelection] = useState({});
const [isLoadingData, setIsLoadingData] = useState(false); const rowSelectionRef = useRef(rowSelection);
const [isPending, startTransition] = useTransition(); rowSelectionRef.current = rowSelection;
const [, startTransition] = useTransition();
const [editingCell, setEditingCell] = useState(null); const [editingCell, setEditingCell] = useState(null);
const [columnsConfig, setColumnsConfig] = useState(null); const [columnsConfig, setColumnsConfig] = useState(null);
const { errors: validationErrors, isCellInvalid } = useValidationRules( const { errors: validationErrors, isCellInvalid } = useValidationRules(
@ -60,13 +113,12 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
const [isOpenModalSelectVsp, setIsOpenModalSelectVsp] = useState(false); const [isOpenModalSelectVsp, setIsOpenModalSelectVsp] = useState(false);
const [isOpenModalSelectExpenseItem, setIsOpenModalSelectExpenseItem] = useState(false); const [isOpenModalSelectExpenseItem, setIsOpenModalSelectExpenseItem] = useState(false);
const [isOpenModalCreateProgram, setIsOpenModalCreateProgram] = useState(false); const [createModalType, setCreateModalType] = useState(null);
const [isOpenModalCreateProject, setIsOpenModalCreateProject] = useState(false);
const [isProgramCreate, setIsProgramCreate] = useState(false)
const [isFormNoCanAddRow, setIsFormNoCanAddRow] = useState(() => { const isFormNoCanAddRow = useMemo(
return (FormsNoCanAddRow[formType].includes(sheetName)); () => FormsNoCanAddRow[formType]?.includes(sheetName) ?? false,
}) [formType, sheetName],
);
const isVspAdditionRow = useMemo(() => { const isVspAdditionRow = useMemo(() => {
if (!formType || !sheetName) return false; if (!formType || !sheetName) return false;
@ -80,21 +132,18 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
return additionExpenseRowTable[formType].includes(sheetName); return additionExpenseRowTable[formType].includes(sheetName);
}, [formType, sheetName]); }, [formType, sheetName]);
const handleGlobalFilterChange = useCallback( const handleGlobalFilterChange = useMemo(
debounce((value) => { () => debounce((value) => {
startTransition(() => { startTransition(() => {
setGlobalFilter(value); setGlobalFilter(value);
}); });
}, 300), }, 300),
[] [startTransition],
); );
useEffect(() => () => handleGlobalFilterChange.clear(), [handleGlobalFilterChange]);
const { const {
isConnected,
isAuthenticated,
subscribeToErrors,
subscribeToAuthSuccess,
error: wsError,
updateCell: contextUpdateCell, updateCell: contextUpdateCell,
addRow: contextAddRow, addRow: contextAddRow,
deleteRow: contextDeleteRow, deleteRow: contextDeleteRow,
@ -118,6 +167,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
const { headerPortalRef, containerRef } = useHeaderPortal(); const { headerPortalRef, containerRef } = useHeaderPortal();
const rowVirtualizerRef = useRef(null); const rowVirtualizerRef = useRef(null);
const columnVirtualizerRef = useRef(null);
const { sizeMult, setSizeMult, tableScaleStyle, tableWrapperStyle } = const { sizeMult, setSizeMult, tableScaleStyle, tableWrapperStyle } =
useTableScale(); useTableScale();
@ -126,21 +176,19 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
rowVirtualizerRef.current?.measure?.(); rowVirtualizerRef.current?.measure?.();
}, [sizeMult]); }, [sizeMult]);
const configCache = useRef(new Map());
useEffect(() => { useEffect(() => {
if (!formType || !sheetName) return; if (!formType || !sheetName) return;
const cacheKey = `${formType}_${sheetName}`; const cacheKey = `${formType}_${sheetName}`;
if (configCache.current.has(cacheKey)) { if (CONFIG_CACHE.has(cacheKey)) {
setColumnsConfig(configCache.current.get(cacheKey)); setColumnsConfig(CONFIG_CACHE.get(cacheKey));
return; return;
} }
const loadConfig = async () => { const loadConfig = async () => {
try { try {
const { config } = await import(`./constants/${formType}/${sheetName}.js`); const { config } = await import(`./constants/${formType}/${sheetName}.js`);
configCache.current.set(cacheKey, config.config); CONFIG_CACHE.set(cacheKey, config.config);
setColumnsConfig(config.config); setColumnsConfig(config.config);
} catch (error) { } catch (error) {
console.error(`Failed to load config for ${formType}/${sheetName}:`, error); console.error(`Failed to load config for ${formType}/${sheetName}:`, error);
@ -154,14 +202,6 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
useEffect(() => { useEffect(() => {
if (!formId || !columnsConfig?.columns) return; 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; if (!hasVspDropdown(columnsConfig.columns)) return;
DictVspApi.getDropdownVsp({ form_id: formId }).then(data => { DictVspApi.getDropdownVsp({ form_id: formId }).then(data => {
@ -237,6 +277,28 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
isCellInvalid, 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); const selectedColumnIdRef = useRef(selectedColumnId);
useEffect(() => { useEffect(() => {
@ -248,14 +310,6 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
getSelectedColumnId: () => selectedColumnIdRef.current, getSelectedColumnId: () => selectedColumnIdRef.current,
}), [handleUpdateCell]); }), [handleUpdateCell]);
const ROW_VIRTUALIZER_OPTIONS = {
overscan: 5,
scrollPaddingStart: 0,
scrollPaddingEnd: 0,
estimateSize: () => TABLE_ROW_HEIGHT,
measureElement: (el) => el?.offsetHeight || TABLE_ROW_HEIGHT,
};
const createTableConfig = ({ const createTableConfig = ({
columns, columns,
data, data,
@ -280,27 +334,14 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
enableRowVirtualization: true, enableRowVirtualization: true,
enableColumnVirtualization: true, enableColumnVirtualization: true,
rowVirtualizerInstanceRef: rowVirtualizerRef, rowVirtualizerInstanceRef: rowVirtualizerRef,
columnVirtualizerInstanceRef: columnVirtualizerRef,
rowVirtualizerOptions: ROW_VIRTUALIZER_OPTIONS, rowVirtualizerOptions: ROW_VIRTUALIZER_OPTIONS,
onEditingCellChange: (cell) => { onEditingCellChange: (cell) => {
if (cell && !editingCell) { if (cell && !editingCell) {
contextStartEditing?.(cell.row, cell.column); contextStartEditing?.(cell.row, cell.column);
} }
}, },
columnVirtualizerOptions: ({ table }) => ({ columnVirtualizerOptions: getColumnVirtualizerOptions,
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;
},
}),
enableRowSelection: true, enableRowSelection: true,
onRowSelectionChange: setRowSelection, onRowSelectionChange: setRowSelection,
onColumnSizingChange: setColumnSizing, onColumnSizingChange: setColumnSizing,
@ -391,6 +432,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
]); ]);
const table = useMaterialReactTable(tableConfig); const table = useMaterialReactTable(tableConfig);
const isLoadingData = columns.length > 0;
const handleNavigateToColumn = useCallback((columnId) => { const handleNavigateToColumn = useCallback((columnId) => {
if (!columnId) return; if (!columnId) return;
@ -426,7 +468,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
} }
if (editingCell) return; if (editingCell) return;
requestAnimationFrame(() => { const animationFrameId = requestAnimationFrame(() => {
try { try {
const row = table.getRow(dataEditingCells.line_id); const row = table.getRow(dataEditingCells.line_id);
const cell = row?.getVisibleCells().find( const cell = row?.getVisibleCells().find(
@ -437,88 +479,72 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
console.error(error); console.error(error);
} }
}); });
return () => cancelAnimationFrame(animationFrameId);
}, [dataEditingCells, editingCell, table]); }, [dataEditingCells, editingCell, table]);
const [isFirstLoad, setIsFirstLoad] = useState(true); const getSelectedRow = useCallback(
() => table.getRowModel().rows.find((row) => rowSelectionRef.current[row.id]),
useEffect(() => { [table],
if (isFirstLoad && table && data && columns.length !== 0) { );
setIsFirstLoad(false);
setIsLoadingData(true);
}
}, [data, columns, table, isFirstLoad]);
const handleAddRow = useCallback(() => { const handleAddRow = useCallback(() => {
const allRows = table.getRowModel().rows; const row = getSelectedRow();
if (!row) {
const selectedRows = allRows.filter(row => rowSelection[row.id]);
if (selectedRows.length === 0) {
toast.error('Выделите строку для вставки'); toast.error('Выделите строку для вставки');
return; return;
} }
const row = selectedRows[0]; contextAddRow({ expense_item_id: row.original.data.header.expense_item_id });
const expense_item_id = row.original.data.header.expense_item_id; }, [contextAddRow, getSelectedRow]);
contextAddRow({ expense_item_id: expense_item_id })
}, [rowSelection, table])
const handleAddVspRow = useCallback((vsp_id) => { const handleAddVspRow = useCallback((vsp_id) => {
contextAddRow({ vsp_id: vsp_id }); contextAddRow({ vsp_id });
}, []); }, [contextAddRow]);
const handleAddExpenseItemRow = useCallback((expense_item) => { const handleAddExpenseItemRow = useCallback((expense_item) => {
const allRows = table.getRowModel().rows; const row = getSelectedRow();
const selectedRows = allRows.filter(row => rowSelection[row.id]); if (!row) return;
const row = selectedRows[0];
contextAddRow({ expense_item_id: expense_item.id, project_id: row.original.data.header.project_id }); contextAddRow({ expense_item_id: expense_item.id, project_id: row.original.data.header.project_id });
}, [rowSelection]); }, [contextAddRow, getSelectedRow]);
const handleAddProgramRow = useCallback((name) => { const handleAddProgramRow = useCallback((name) => {
contextAddProgram({ name: name }); contextAddProgram({ name });
}, []); }, [contextAddProgram]);
const handleAddProjectRow = useCallback((name) => { const handleAddProjectRow = useCallback((name) => {
const allRows = table.getRowModel().rows; const row = getSelectedRow();
const selectedRows = allRows.filter(row => rowSelection[row.id]); if (!row) return;
const row = selectedRows[0]; contextAddProject({ name, program_id: row.original.data.header.program_id });
contextAddProject({ name: name, program_id: row.original.data.header.program_id }); }, [contextAddProject, getSelectedRow]);
}, [rowSelection]);
const handleOpenModalSelectVsp = useCallback(() => { const handleOpenModalSelectVsp = useCallback(() => {
setIsOpenModalSelectVsp(true); setIsOpenModalSelectVsp(true);
}, []) }, []);
const handleOpenModalSelectExpenseItem = useCallback(() => { const handleOpenModalSelectExpenseItem = useCallback(() => {
const allRows = table.getRowModel().rows; const row = getSelectedRow();
if (!row) {
const selectedRows = allRows.filter(row => rowSelection[row.id]);
if (selectedRows.length === 0) {
toast.error('Выделите строку для вставки'); toast.error('Выделите строку для вставки');
return; return;
} }
const row = selectedRows[0];
if (row.original.row_type !== 'ITEM') { if (row.original.row_type !== 'ITEM') {
toast.error('Выделите строку проекта'); toast.error('Выделите строку проекта');
return; return;
} }
setIsOpenModalSelectExpenseItem(true); setIsOpenModalSelectExpenseItem(true);
}, [rowSelection]) }, [getSelectedRow]);
const handleDeleteRow = useCallback(() => { const handleDeleteRow = useCallback(() => {
const allRows = table.getRowModel().rows; const row = getSelectedRow();
if (!row) {
const selectedRows = allRows.filter(row => rowSelection[row.id]);
if (selectedRows.length === 0) {
toast.error('Выделите строку для удаления'); toast.error('Выделите строку для удаления');
return; return;
} }
const row = selectedRows[0]; contextDeleteRow(getRowId(row));
const rowId = getRowId(row);
contextDeleteRow(rowId);
setRowSelection({}); setRowSelection({});
}, [rowSelection, table]) }, [contextDeleteRow, getSelectedRow]);
const addRow = useCallback(() => { const addRow = useCallback(() => {
if (isVspAdditionRow) { if (isVspAdditionRow) {
return handleOpenModalSelectVsp(); return handleOpenModalSelectVsp();
} }
@ -529,35 +555,30 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
}, [isVspAdditionRow, isAdditionExpenseItem, handleOpenModalSelectVsp, handleAddRow, handleOpenModalSelectExpenseItem]); }, [isVspAdditionRow, isAdditionExpenseItem, handleOpenModalSelectVsp, handleAddRow, handleOpenModalSelectExpenseItem]);
const addProgram = useCallback(() => { const addProgram = useCallback(() => {
setIsProgramCreate(true); setCreateModalType('program');
setIsOpenModalCreateProgram(true); }, []);
}, [])
const addProject = useCallback(() => { const addProject = useCallback(() => {
setIsProgramCreate(false); const row = getSelectedRow();
const allRows = table.getRowModel().rows; if (!row) {
const selectedRows = allRows.filter(row => rowSelection[row.id]);
if (selectedRows.length === 0) {
toast.error('Выделите строку для вставки'); toast.error('Выделите строку для вставки');
return; return;
} }
const row = selectedRows[0];
if (row.original.row_type !== 'GROUP') { if (row.original.row_type !== 'GROUP') {
toast.error('Выделите строку программы'); toast.error('Выделите строку программы');
return; return;
} }
setIsOpenModalCreateProgram(true); setCreateModalType('project');
}, [setIsOpenModalCreateProgram, rowSelection]) }, [getSelectedRow]);
const closeSelectVspModal = useCallback(() => setIsOpenModalSelectVsp(false), []);
const closeSelectExpenseItemModal = useCallback(() => setIsOpenModalSelectExpenseItem(false), []);
const closeCreateModal = useCallback(() => setCreateModalType(null), []);
useEffect(() => { useEffect(() => {
return () => { return () => {
setData([]); rowVirtualizerRef.current = null;
setRowSelection({}); columnVirtualizerRef.current = null;
setEditingCell(null);
if (rowVirtualizerRef.current) {
rowVirtualizerRef.current = null;
}
}; };
}, []); }, []);
@ -567,7 +588,6 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
if (newState) { if (newState) {
const allRows = table.getRowModel().flatRows; const allRows = table.getRowModel().flatRows;
console.log(allRows);
const newExpanded = {}; const newExpanded = {};
for (const row of allRows) { for (const row of allRows) {
@ -603,6 +623,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
onGlobalFilterChange={handleGlobalFilterChange} onGlobalFilterChange={handleGlobalFilterChange}
sizeMult={sizeMult} sizeMult={sizeMult}
onChangeShowColumnFilters={setShowColumnFilters} onChangeShowColumnFilters={setShowColumnFilters}
showColumnFilters={showColumnFilters}
onAddRow={addRow} onAddRow={addRow}
onAddProgram={addProgram} onAddProgram={addProgram}
onAddProject={addProject} onAddProject={addProject}
@ -622,15 +643,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
<div style={tableWrapperStyle}> <div style={tableWrapperStyle}>
<div style={tableScaleStyle}> <div style={tableScaleStyle}>
<div <div style={tableContentStyle}>
style={{
position: 'relative',
width: '100%',
flex: 1,
minHeight: 0,
height: '100%',
}}
>
<MaterialReactTable table={table} /> <MaterialReactTable table={table} />
<ColumnSelectionOverlay <ColumnSelectionOverlay
@ -643,17 +656,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
/> />
{isTableLoading && ( {isTableLoading && (
<div <div style={loadingOverlayStyle}>
style={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'rgba(255, 255, 255, 0.6)',
zIndex: 2,
}}
>
<CircularProgress /> <CircularProgress />
</div> </div>
)} )}
@ -662,7 +665,11 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
createPortal( createPortal(
<TableHead <TableHead
table={table} table={table}
style={{ zIndex: (theme) => theme.zIndex.modal - 1 }} columns={columns}
columnPinning={columnPinning}
columnSizing={columnSizing}
columnVisibility={columnVisibility}
showColumnFilters={showColumnFilters}
selectedColumnId={selectedColumnId} selectedColumnId={selectedColumnId}
onColumnSelect={handleColumnSelect} onColumnSelect={handleColumnSelect}
onChangeWidth={handleSetColumnWidth} onChangeWidth={handleSetColumnWidth}
@ -675,24 +682,24 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
</div> </div>
<SelectVspModal <SelectVspModal
isOpen={isOpenModalSelectVsp} isOpen={isOpenModalSelectVsp}
onClose={() => setIsOpenModalSelectVsp(false)} onClose={closeSelectVspModal}
formId={formId} formId={formId}
onSelect={handleAddVspRow} onSelect={handleAddVspRow}
title="Выбор ВСП" title="Выбор ВСП"
/> />
<SelectExpenseItemModal <SelectExpenseItemModal
isOpen={isOpenModalSelectExpenseItem} isOpen={isOpenModalSelectExpenseItem}
onClose={() => setIsOpenModalSelectExpenseItem(false)} onClose={closeSelectExpenseItemModal}
formId={formId} formId={formId}
onSelect={handleAddExpenseItemRow} onSelect={handleAddExpenseItemRow}
title="Добавление строки" title="Добавление строки"
sheet={sheetName} sheet={sheetName}
/> />
<CreateProgramModal <CreateProgramModal
isOpen={isOpenModalCreateProgram} isOpen={createModalType !== null}
onClose={() => setIsOpenModalCreateProgram(false)} onClose={closeCreateModal}
title={isProgramCreate ? "Создание программы" : "Создание проекта"} title={createModalType === 'program' ? 'Создание программы' : 'Создание проекта'}
onCreate={isProgramCreate ? handleAddProgramRow : handleAddProjectRow} onCreate={createModalType === 'program' ? handleAddProgramRow : handleAddProjectRow}
/> />
</> </>
); );

View File

@ -1,5 +1,5 @@
import { Divider, IconButton, Stack, Tooltip } from '@mui/material'; 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 { FORM_TYPE_OPTIONS } from '../../../constants/constants';
import { exportSheet, exportSheetProject } from '../../../utils/exportFile'; import { exportSheet, exportSheetProject } from '../../../utils/exportFile';
import { ExportDefaultButton } from '../../common/Buttons/ButtonsActions'; import { ExportDefaultButton } from '../../common/Buttons/ButtonsActions';
@ -31,6 +31,9 @@ const controlSx = {
flex: '0 0 2.5rem', flex: '0 0 2.5rem',
}; };
const searchSx = { height: '2.5rem' };
const exportButtonSx = { height: '2.5rem', minHeight: '2.5rem' };
const SettingPanel = ({ const SettingPanel = ({
selectedColumnId, selectedColumnId,
columnPinning, columnPinning,
@ -45,6 +48,7 @@ const SettingPanel = ({
onGlobalFilterChange, onGlobalFilterChange,
sizeMult, sizeMult,
onChangeShowColumnFilters, onChangeShowColumnFilters,
showColumnFilters,
onAddRow, onAddRow,
onAddProgram, onAddProgram,
onAddProject, onAddProject,
@ -61,49 +65,43 @@ const SettingPanel = ({
showOnlyDepth2, showOnlyDepth2,
onToggleDepthVisibility, onToggleDepthVisibility,
}) => { }) => {
const [isPinned, setIsPinned] = useState(false);
const [isExporting, setIsExporting] = useState(false); const [isExporting, setIsExporting] = useState(false);
const [selectedColumnIds, setSelectedColumnIds] = useState(); const isPinned = useMemo(
const [showColumnFilters, setShowColumnFilters] = useState(false); () => Boolean(selectedColumnId && columnPinning?.left?.includes(selectedColumnId)),
[columnPinning, selectedColumnId],
useEffect(() => { );
if (!selectedColumnId) { const selectedColumnIds = useMemo(
setIsPinned(false); () => Object.keys(columnVisibility).filter((key) => !columnVisibility[key]),
return; [columnVisibility],
} );
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]);
//TO DO: сделать //TO DO: сделать
const { addColorForCells } = {}; const { addColorForCells } = {};
const handleChangeShowColumnFilters = () => { const handleChangeShowColumnFilters = useCallback(() => {
const show = table.getState().showColumnFilters; onChangeShowColumnFilters(!showColumnFilters);
setShowColumnFilters(!show); }, [onChangeShowColumnFilters, showColumnFilters]);
onChangeShowColumnFilters(!show);
};
const handleExport = async () => { const handleExport = useCallback(async () => {
if (isExporting) return; if (isExporting) return;
if (formType === 'PROJECT') { if (!formId || !sheetName || (formType === 'PROJECT' && !year)) return;
if (!formId || !sheetName || !year) return;
setIsExporting(true);
await exportSheetProject(formId, sheetName, year);
setIsExporting(false);
return;
}
if (!formId || !sheetName) return;
setIsExporting(true); setIsExporting(true);
await exportSheet(formId, sheetName, direction); try {
setIsExporting(false); 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 leafColumns = useMemo(() => collectLeafColumns({ columns }), [columns]);
const stageColumnIds = useMemo( const stageColumnIds = useMemo(
() => new Set([...stageColumnsHiddenFromPicker, ...(columnsCurStage || [])]), () => new Set([...stageColumnsHiddenFromPicker, ...(columnsCurStage || [])]),
@ -140,13 +138,7 @@ const SettingPanel = ({
data-active={isPinned} data-active={isPinned}
disabled={!selectedColumnId} disabled={!selectedColumnId}
onMouseDown={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}
onClick={() => { onClick={handleTogglePin}>
if (!selectedColumnId) {
console.warn('[ColumnPin] клик Pin: selectedColumnId пустой');
return;
}
isPinned ? onUnpinColumn?.(selectedColumnId) : onPinColumn?.(selectedColumnId);
}}>
<Pin /> <Pin />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
@ -223,7 +215,7 @@ const SettingPanel = ({
</GroupByObject> </GroupByObject>
<Divider orientation='vertical' flexItem /> <Divider orientation='vertical' flexItem />
<GroupByObject title='Поиск'> <GroupByObject title='Поиск'>
<SearchComponent onChange={onGlobalFilterChange} height='2.5rem' sx={{ height: '2.5rem' }} /> <SearchComponent onChange={onGlobalFilterChange} height='2.5rem' sx={searchSx} />
<Tooltip title='Поиск по колонкам'> <Tooltip title='Поиск по колонкам'>
<IconButton <IconButton
variant='outlined' variant='outlined'
@ -243,7 +235,7 @@ const SettingPanel = ({
onClick={handleExport} onClick={handleExport}
disabled={!formId || !sheetName || isExporting} disabled={!formId || !sheetName || isExporting}
text={isExporting ? 'Экспорт...' : 'Экспорт'} text={isExporting ? 'Экспорт...' : 'Экспорт'}
sx={{ height: '2.5rem', minHeight: '2.5rem' }} sx={exportButtonSx}
/> />
</GroupByObject> </GroupByObject>
</Stack> </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 React, { useMemo } from 'react';
import { columnPinningDefault } from '../constants/columnConfig';
import ColumnResizer from './ColumnResizer'; import ColumnResizer from './ColumnResizer';
const getPinnedColumnIds = (table) => (table.getState().columnPinning.left || []).filter(Boolean);
const getSortedColumns = (table) => { const getSortedColumns = (table) => {
const allLeafColumns = table.getAllLeafColumns(); return [
const pinnedColumns = getPinnedColumnIds(table); ...table.getLeftVisibleLeafColumns(),
...table.getCenterVisibleLeafColumns(),
return [...allLeafColumns].sort((a, b) => { ...table.getRightVisibleLeafColumns(),
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;
});
}; };
const getColumnId = (header) => { const getColumnPinnedStyles = (column) => {
//в header.id содержаться колонки по типу depth_idColParent_idChild, а в pinningColumn только idCol, которые закреплены const pinningPosition = column.getIsPinned();
//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;
};
// Ширина закреплённых дочерних колонок группового заголовка return {
const getPinnedColumnsWidth = (header, pinningColumn) => { isPinned: Boolean(pinningPosition),
let width = 0; left: pinningPosition === 'left' ? column.getStart('left') : undefined,
for (const leaf of header.getLeafHeaders()) { right: pinningPosition === 'right' ? column.getAfter('right') : undefined,
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 }) => { const ColumnNumbersRow = ({ table, selectedColumnId, onColumnSelect }) => {
@ -102,10 +49,9 @@ const ColumnNumbersRow = ({ table, selectedColumnId, onColumnSelect }) => {
}; };
const ColumnNumberCell = ({ column, 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 allLeafColumns = table.getAllLeafColumns();
const originalIndex = allLeafColumns.findIndex((col) => col.id === column.id); const originalIndex = allLeafColumns.findIndex((col) => col.id === column.id);
const leftOffset = calculateLeftOffset(table, column);
const selected = selectedColumnId === column.id; const selected = selectedColumnId === column.id;
return ( return (
@ -133,7 +79,8 @@ const ColumnNumberCell = ({ column, table, selectedColumnId, onColumnSelect }) =
border: '.063rem solid rgba(209, 213, 220, 1)', border: '.063rem solid rgba(209, 213, 220, 1)',
borderBottom: 'none', borderBottom: 'none',
position: isPinned ? 'sticky' : 'relative', position: isPinned ? 'sticky' : 'relative',
left: isPinned ? leftOffset : 'auto', left,
right,
zIndex: selected ? 16 : isPinned ? 20 : 'auto', zIndex: selected ? 16 : isPinned ? 20 : 'auto',
cursor: 'pointer', cursor: 'pointer',
userSelect: 'none', userSelect: 'none',
@ -161,15 +108,15 @@ const getColorBrightness = (hexColor) => {
return 0.299 * r + 0.587 * g + 0.114 * b; 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 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 headerSize = header.getSize();
const pinnedBandWidth = isGroup ? getPinnedColumnsWidth(header, pinningColumn) : 0; const isPinned = Boolean(pinningPosition);
const splitGroup = isGroup && isPinned && pinnedBandWidth > 0 && pinnedBandWidth < headerSize; const leftOffset = pinningPosition === 'left' ? header.getStart() : undefined;
const rightOffset =
pinningPosition === 'right'
? table.getRightTotalSize() - header.getStart() - headerSize
: undefined;
const customBgColor = column.columnDef?.muiTableHeadCellProps?.sx?.backgroundColor; const customBgColor = column.columnDef?.muiTableHeadCellProps?.sx?.backgroundColor;
@ -185,34 +132,24 @@ const HeaderCell = ({ header, table, onClick, onChangeWidth }) => {
onChangeWidth(header, size + deltaWidth); onChangeWidth(header, size + deltaWidth);
}; };
// Вычисляем отступ для текста внутри ячейки return (
// Если колонка закреплена, текст должен быть привязан к левому краю ячейки <div
// Если колонка не закреплена, текст должен начинаться после всех закрепленных колонок style={{
const getTextLeftOffset = () => { position: isPinned ? 'sticky' : 'relative',
if (isPinned) { left: leftOffset,
return '0'; right: rightOffset,
} zIndex: isPinned ? 21 : 'auto',
// Для незакрепленных колонок текст должен начинаться после всех закрепленных width: `${headerSize}px`,
// Получаем общую ширину всех закрепленных колонок minWidth: `${headerSize}px`,
const pinnedColumns = getPinnedColumnIds(table); maxWidth: `${headerSize}px`,
let totalPinnedWidth = 0; flex: '0 0 auto',
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' }}>
<div <div
onClick={() => onClick(header)} onClick={() => onClick(header)}
style={{ style={{
width: width + 'px', width: '100%',
minWidth: width + 'px', minWidth: '100%',
maxWidth: width + 'px', maxWidth: '100%',
position: 'relative', position: 'relative',
padding: '4px 6px', padding: '4px 6px',
textAlign: 'center', textAlign: 'center',
@ -231,9 +168,7 @@ const HeaderCell = ({ header, table, onClick, onChangeWidth }) => {
<div <div
style={{ style={{
textAlign: 'center', textAlign: 'center',
position: 'sticky', position: 'relative',
left: textLeft,
right: 0,
paddingRight: '0.5rem', paddingRight: '0.5rem',
paddingLeft: '0.5rem', paddingLeft: '0.5rem',
backgroundColor: backgroundColor, backgroundColor: backgroundColor,
@ -245,26 +180,6 @@ const HeaderCell = ({ header, table, onClick, onChangeWidth }) => {
</div> </div>
</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 }) => { const FilterRow = ({ table }) => {
@ -293,8 +208,7 @@ const FilterRow = ({ table }) => {
// Компонент ячейки фильтра // Компонент ячейки фильтра
const FilterCell = ({ column, table }) => { const FilterCell = ({ column, table }) => {
const isPinned = isColumnPin(table, column); const { isPinned, left, right } = getColumnPinnedStyles(column);
const leftOffset = calculateLeftOffset(table, column);
// Определяем тип фильтра на основе колонки // Определяем тип фильтра на основе колонки
const filterVariant = column.columnDef.filterVariant || 'text'; const filterVariant = column.columnDef.filterVariant || 'text';
@ -396,7 +310,8 @@ const FilterCell = ({ column, table }) => {
border: '.063rem solid rgba(209, 213, 220, 1)', border: '.063rem solid rgba(209, 213, 220, 1)',
borderTop: 'none', borderTop: 'none',
position: isPinned ? 'sticky' : 'relative', position: isPinned ? 'sticky' : 'relative',
left: isPinned ? leftOffset : 'auto', left,
right,
zIndex: isPinned ? 19 : 'auto', zIndex: isPinned ? 19 : 'auto',
}}> }}>
{renderFilterInput()} {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) => { const handleHeaderClick = (header) => {
if (!header.subHeaders?.length && header.column?.id) { if (!header.subHeaders?.length && header.column?.id) {
onColumnSelect?.(header.column.id); onColumnSelect?.(header.column.id);
@ -415,6 +330,13 @@ export const TableHead = ({ table, selectedColumnId, onColumnSelect, onChangeWid
return <div></div>; 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 ( return (
<> <>
<div <div
@ -428,17 +350,26 @@ export const TableHead = ({ table, selectedColumnId, onColumnSelect, onChangeWid
{table.getState().showColumnFilters && <FilterRow table={table} />} {table.getState().showColumnFilters && <FilterRow table={table} />}
{/* Существующие строки заголовков */} {/* Pinned и обычные заголовки строятся отдельными группами. */}
{table.getHeaderGroups().map((headerGroup) => ( {Array.from({ length: headerRowsCount }, (_, rowIndex) => (
<div key={headerGroup.id} style={{ display: 'flex' }}> <div key={`header-row-${rowIndex}`} style={{ display: 'flex' }}>
{headerGroup.headers.map((header) => ( {headerSections.flatMap(({ groups, pinningPosition }) =>
<React.Fragment key={header.id}> (groups[rowIndex]?.headers || []).map((header) => (
<HeaderCell header={header} table={table} onClick={handleHeaderClick} onChangeWidth={onChangeWidth} /> <HeaderCell
</React.Fragment> key={`${pinningPosition || 'center'}-${header.id}`}
))} header={header}
table={table}
pinningPosition={pinningPosition}
onClick={handleHeaderClick}
onChangeWidth={onChangeWidth}
/>
)),
)}
</div> </div>
))} ))}
</div> </div>
</> </>
); );
}; };
export const TableHead = React.memo(TableHeadComponent);