исправила иерархию, блокировка ячеек, виртуализация строк

This commit is contained in:
PotapovaA 2026-05-29 18:38:05 +03:00
parent fcece07c64
commit 56833f454b
10 changed files with 111 additions and 96 deletions

View File

@ -1,7 +1,8 @@
import React from 'react'; import React, { useEffect, useState } from 'react';
import { useMemo } from 'react'; import { useMemo } from 'react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { useRealtime } from '../../contexts/RealtimeContext'; import { useRealtime } from '../../contexts/RealtimeContext';
import { toast } from 'react-toastify';
const ContainerForText = styled.span` const ContainerForText = styled.span`
@ -42,7 +43,7 @@ const highlightText = (text, searchQuery) => {
); );
}; };
const Cell = React.memo(({ cell, row, column, onClick, globalFilter, backgroundColor, color }) => { const Cell = React.memo(({ cell, row, column, onClick, globalFilter, backgroundColor, color, isEditable }) => {
const cellValue = cell.getValue(); const cellValue = cell.getValue();
const textValue = String(cellValue || ''); const textValue = String(cellValue || '');
@ -51,14 +52,11 @@ const Cell = React.memo(({ cell, row, column, onClick, globalFilter, backgroundC
return highlightText(textValue, globalFilter); return highlightText(textValue, globalFilter);
}, [textValue, globalFilter]); }, [textValue, globalFilter]);
const { const lockedStyles = !isEditable ? {
isCellLocked, border: '2px solid #d3d3d3',
getCellLockInfo, color: '#525252',
updateCell, cursor: 'not-allowed',
subscribeToErrors, } : {};
tryLockCell,
unlockCell
} = useRealtime();
return ( return (
@ -80,6 +78,7 @@ const Cell = React.memo(({ cell, row, column, onClick, globalFilter, backgroundC
padding: '8px', padding: '8px',
backgroundColor: backgroundColor, backgroundColor: backgroundColor,
color: color, color: color,
...lockedStyles
}} }}
onClick={onClick} onClick={onClick}
> >

View File

@ -7,7 +7,7 @@ const EditCell = ({ refCell, onChange, disabled, value }) => {
const left = tdFrag.offsetLeft; const left = tdFrag.offsetLeft;
const width = refCell.current.offsetParent.offsetWidth; const width = refCell.current.offsetParent.offsetWidth;
const translateY = 'translateY(' + trFrag.offsetTop + 'px)'; const translateY = trFrag.style.transform;
function useAutoResize(value) { function useAutoResize(value) {
const ref = useRef(null); const ref = useRef(null);

View File

@ -1,5 +1,5 @@
// components/RealtimeTable/index.js // components/RealtimeTable/index.js
import React, { useState, useMemo, useRef, useEffect, useCallback } from 'react'; import React, { useState, useMemo, useRef, useEffect, useCallback, useTransition } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { import {
useMaterialReactTable, useMaterialReactTable,
@ -25,12 +25,18 @@ import { toast } from 'react-toastify';
const RealtimeTable = ({ formType, formId, sheetName, direction }) => { const RealtimeTable = ({ formType, formId, sheetName, direction }) => {
const { data, setData } = useRealtimeData(formId, sheetName, direction); const { data, setData } = useRealtimeData(formId, sheetName, direction);
const [isLoading, setIsLoading] = useState(true);
const [globalFilter, setGlobalFilter] = useState(''); const [globalFilter, setGlobalFilter] = useState('');
const [showColumnFilters, setShowColumnFilters] = useState(false); const [showColumnFilters, setShowColumnFilters] = useState(false);
const [selectedColumn, setSelectedColumn] = useState(); const [selectedColumn, setSelectedColumn] = useState();
const columnVirtualizerInstanceRef = useRef(null);
const [rowSelection, setRowSelection] = useState({}); const [rowSelection, setRowSelection] = useState({});
const [isLoadingData, setIsLoadingData] = useState(false);
const [isPending, startTransition] = useTransition();
const handleGlobalFilterChange = (value) => {
startTransition(() => {
setGlobalFilter(value);
});
};
const { const {
isConnected, isConnected,
@ -85,7 +91,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction }) => {
setRowSelection((prev) => ({ setRowSelection((prev) => ({
[rowId]: !prev[rowId], [rowId]: !prev[rowId],
})); }));
}, [rowSelection]) }, [])
const columns = useMemo(() => { const columns = useMemo(() => {
@ -108,7 +114,6 @@ const RealtimeTable = ({ formType, formId, sheetName, direction }) => {
} }
}, [columnsConfig, handleUpdateCell]); }, [columnsConfig, handleUpdateCell]);
const tableMeta = useMemo(() => ({ const tableMeta = useMemo(() => ({
updateCell: handleUpdateCell, updateCell: handleUpdateCell,
}), [handleUpdateCell]); }), [handleUpdateCell]);
@ -119,13 +124,17 @@ const RealtimeTable = ({ formType, formId, sheetName, direction }) => {
...BASE_TABLE_CONFIG, ...BASE_TABLE_CONFIG,
columns, columns,
data: data, data: data,
columnVirtualizerInstanceRef, enableRowVirtualization: true,
enableRowVirtualization: false, rowVirtualizerOptions: {
overscan: 50,
scrollPaddingStart: 0,
scrollPaddingEnd: 0,
},
enableRowSelection: true, enableRowSelection: true,
onRowSelectionChange: setRowSelection, onRowSelectionChange: setRowSelection,
onColumnSizingChange: setColumnSizing, onColumnSizingChange: setColumnSizing,
onColumnPinningChange: setColumnPinning, onColumnPinningChange: setColumnPinning,
onGlobalFilterChange: setGlobalFilter, onGlobalFilterChange: handleGlobalFilterChange,
state: { state: {
columnSizing, columnSizing,
columnPinning, columnPinning,
@ -135,7 +144,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction }) => {
rowSelection rowSelection
}, },
initialState: { initialState: {
hiddenColumn: [] expanded: true
}, },
meta: tableMeta, meta: tableMeta,
muiTableHeadCellProps: ({ column }) => ({ muiTableHeadCellProps: ({ column }) => ({
@ -153,22 +162,13 @@ const RealtimeTable = ({ formType, formId, sheetName, direction }) => {
height: '1px', height: '1px',
minHeight: '1px', minHeight: '1px',
maxHeight: '1px', maxHeight: '1px',
opacity: 0, visibility: 'hidden',
pointerEvents: 'none',
backgroundColor: 'transparent',
'& th': {
height: '1px',
minHeight: '1px',
maxHeight: '1px',
},
}, },
}, },
muiTablePaperProps: getTablePaperStyles(), muiTablePaperProps: getTablePaperStyles(),
muiTableContainerProps: { muiTableContainerProps: {
ref: containerRef, ref: containerRef,
sx: { sx: {
overflowX: 'auto',
overflowY: 'auto',
position: 'relative', position: 'relative',
contain: 'layout', contain: 'layout',
minHeight: '100%', minHeight: '100%',
@ -198,6 +198,12 @@ const RealtimeTable = ({ formType, formId, sheetName, direction }) => {
const table = useMaterialReactTable(tableConfig); const table = useMaterialReactTable(tableConfig);
useEffect(() => {
if (table && data && columns.length !== 0) {
setIsLoadingData(true)
}
}, [data, columns, table])
const handleAddRow = useCallback(() => { const handleAddRow = useCallback(() => {
const allRows = table.getRowModel().rows; const allRows = table.getRowModel().rows;
@ -222,7 +228,9 @@ const RealtimeTable = ({ formType, formId, sheetName, direction }) => {
const row = selectedRows[0]; const row = selectedRows[0];
const expense_item_id = row.original.data.line_id; const expense_item_id = row.original.data.line_id;
contextDeleteRow(expense_item_id) contextDeleteRow(expense_item_id)
setRowSelection({});
}, [rowSelection, table]) }, [rowSelection, table])
if (!data) { if (!data) {
return <div>Загрузка...</div>; return <div>Загрузка...</div>;
} }
@ -237,11 +245,12 @@ const RealtimeTable = ({ formType, formId, sheetName, direction }) => {
onToggleColumnVisibility={handleToggleColumnVisibility} onToggleColumnVisibility={handleToggleColumnVisibility}
columnVisibility={columnVisibility} columnVisibility={columnVisibility}
onChangeSizeMult={setSizeMult} onChangeSizeMult={setSizeMult}
onGlobalFilterChange={setGlobalFilter} onGlobalFilterChange={handleGlobalFilterChange}
sizeMult={sizeMult} sizeMult={sizeMult}
onChangeShowColumnFilters={setShowColumnFilters} onChangeShowColumnFilters={setShowColumnFilters}
onAddRow={handleAddRow} onAddRow={handleAddRow}
onDeleteRow={handleDeleteRow} onDeleteRow={handleDeleteRow}
isLoadingData={isLoadingData}
/> />
<div style={tableWrapperStyle}> <div style={tableWrapperStyle}>
@ -265,6 +274,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction }) => {
onHeaderSelect={setSelectedColumn} onHeaderSelect={setSelectedColumn}
selectHeader={selectedColumn} selectHeader={selectedColumn}
onChangeWidth={handleSetColumnWidth} onChangeWidth={handleSetColumnWidth}
isLoadingData={isLoadingData}
/>, />,
headerPortalRef.current, headerPortalRef.current,
)} )}

View File

@ -31,6 +31,7 @@ const SettingPanel = ({
onChangeShowColumnFilters, onChangeShowColumnFilters,
onAddRow, onAddRow,
onDeleteRow, onDeleteRow,
isLoadingData,
}) => { }) => {
const [isPinned, setIsPinned] = useState(false); const [isPinned, setIsPinned] = useState(false);
const [columnTree, setColumnTree] = useState(); const [columnTree, setColumnTree] = useState();
@ -41,7 +42,7 @@ const SettingPanel = ({
if (!table) return; if (!table) return;
setColumnTree(table.getAllColumns()); setColumnTree(table.getAllColumns());
setShowColumnFilters(table.getState().showColumnFilters); setShowColumnFilters(table.getState().showColumnFilters);
}, [table]); }, [table, isLoadingData]);
useEffect(() => { useEffect(() => {
if (!selectedColumn) return; if (!selectedColumn) return;

View File

@ -131,7 +131,6 @@ const HeaderCell = ({ header, table, onClick, hightlight, onChangeWidth }) => {
// Если колонка не закреплена, текст должен начинаться после всех закрепленных колонок // Если колонка не закреплена, текст должен начинаться после всех закрепленных колонок
const getTextLeftOffset = () => { const getTextLeftOffset = () => {
if (isPinned) { if (isPinned) {
// Для закрепленных колонок текст остается на месте
return '0'; return '0';
} }
// Для незакрепленных колонок текст должен начинаться после всех закрепленных // Для незакрепленных колонок текст должен начинаться после всех закрепленных
@ -215,7 +214,7 @@ const FilterRow = ({ table }) => {
style={{ style={{
display: 'flex', display: 'flex',
position: 'sticky', position: 'sticky',
top: '1.5rem', // После строки с номерами колонок top: '1.5rem',
zIndex: 20, zIndex: 20,
backgroundColor: 'white', backgroundColor: 'white',
}} }}
@ -338,11 +337,16 @@ export const TableHead = ({
onHeaderSelect, onHeaderSelect,
selectHeader, selectHeader,
onChangeWidth, onChangeWidth,
isLoadingData,
}) => { }) => {
const handleHeaderClick = (header) => { const handleHeaderClick = (header) => {
onHeaderSelect(header); onHeaderSelect(header);
}; };
if (!isLoadingData) {
return <div></div>
}
return ( return (
<> <>
<div <div

View File

@ -86,15 +86,8 @@ const useRealtimeData = (formId, sheetName, direction) => {
return; return;
} }
const maxDepth = data.reduce( const hierarchicalData = buildHierarchy(data);
(prev, current) => { setData(hierarchicalData);
return (prev.depth || 0) > (current.depth || 0) ? prev : current;
},
{ depth: 0 },
).depth;
const formattedData = formatedToSubRows(maxDepth, data);
setData(formattedData);
} catch (err) { } catch (err) {
console.error("Failed to load initial data:", err); console.error("Failed to load initial data:", err);
setError(err.message); setError(err.message);
@ -104,7 +97,54 @@ const useRealtimeData = (formId, sheetName, direction) => {
}; };
loadInitialData(); loadInitialData();
}, [formId, sheetName, formatedToSubRows]); }, [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;
};
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(() => { useEffect(() => {
setIsConnected(wsConnected); setIsConnected(wsConnected);

View File

@ -9,7 +9,8 @@ function EditCellPortal({
EditCell, EditCell,
onSaveStart, onSaveStart,
onSaveEnd, onSaveEnd,
onError onError,
isEditable
}) { }) {
const ref = useRef(null); const ref = useRef(null);
const [refTbody, setRefTbody] = useState(false); const [refTbody, setRefTbody] = useState(false);
@ -25,17 +26,7 @@ function EditCellPortal({
const handleChange = async (val) => { const handleChange = async (val) => {
table.setEditingCell(null); table.setEditingCell(null);
if (isSaving) return;
setIsSaving(true);
onSaveStart?.();
// Сохраняем старое значение на случай ошибки
const oldValue = cell.getValue();
try { try {
// Используем updateCell из контекста (передается через meta)
if (table.options.meta?.updateCell) { if (table.options.meta?.updateCell) {
const success = await table.options.meta.updateCell( const success = await table.options.meta.updateCell(
cell.row, cell.row,
@ -44,32 +35,15 @@ function EditCellPortal({
); );
if (success) { if (success) {
console.log('Cell updated successfully via WebSocket'); console.log('Cell updated successfully via WebSocket');
onSaveEnd?.(true);
table.setEditingCell(null);
} else { } else {
console.error('Failed to update cell via WebSocket'); console.error('Failed to update cell via WebSocket');
const errorMsg = 'Не удалось сохранить изменение'; const errorMsg = 'Не удалось сохранить изменение';
onError?.(errorMsg); onError?.(errorMsg);
onSaveEnd?.(false);
// Возвращаем предыдущее значение
} }
} else {
// Если updateCell нет в meta, используем onCellUpdate из пропсов (fallback)
console.warn('WebSocket updateCell not configured in meta');
if (table.options.meta?.onCellUpdate) {
table.options.meta.onCellUpdate(cell.row.id, cell.column.id, val);
}
onSaveEnd?.(true);
table.setEditingCell(null);
} }
} catch (error) { } catch (error) {
console.error('Error updating cell:', error); console.error('Error updating cell:', error);
onError?.(error.message); onError?.(error.message);
onSaveEnd?.(false);
} finally {
setIsSaving(false);
} }
}; };
@ -88,7 +62,7 @@ function EditCellPortal({
refCell={ref} refCell={ref}
cell={cell} cell={cell}
value={cell.getValue()} value={cell.getValue()}
disabled={isDisabled || isSaving} disabled={!isEditable}
onChange={handleChange} onChange={handleChange}
/>, />,
refTbody, refTbody,
@ -131,8 +105,9 @@ export const getTableColumns = ({
column={column} column={column}
cell={cell} cell={cell}
globalFilter={ globalFilter={
table.getState().globalFilter || column.getFilterValue() table.getState().globalFilter
} }
isEditable={row.original?.row_type === 'INPUT' || false}
backgroundColor={columnColors[column.id]?.color_type?.[row.original?.row_type || row.row_type]} backgroundColor={columnColors[column.id]?.color_type?.[row.original?.row_type || row.row_type]}
color={columnColors[column.id]?.color_type?.['COLOR']} color={columnColors[column.id]?.color_type?.['COLOR']}
isUpdating={table.options.meta?.updatingCells?.[`${row.id}_${column.id}`]} isUpdating={table.options.meta?.updatingCells?.[`${row.id}_${column.id}`]}
@ -144,20 +119,8 @@ export const getTableColumns = ({
cell={cell} cell={cell}
table={table} table={table}
EditCell={EditCell} EditCell={EditCell}
onSaveStart={() => {
if (table.options.meta?.setUpdatingCell) {
table.options.meta.setUpdatingCell(row.id, column.id, true);
}
}}
onSaveEnd={(success) => {
if (table.options.meta?.setUpdatingCell) {
table.options.meta.setUpdatingCell(row.id, column.id, false);
}
if (!success && onCellUpdateError) {
onCellUpdateError(row.id, column.id, 'Failed to save');
}
}}
onError={onCellUpdateError} onError={onCellUpdateError}
isEditable={row.original?.row_type === 'INPUT' || false}
/> />
); );
@ -177,10 +140,9 @@ export const getTableColumns = ({
enableEditing: false, enableEditing: false,
enablePinning: true, enablePinning: true,
Cell: ({ cell, row }) => { Cell: ({ cell, row }) => {
return <span return <button onClick={() => { console.log('TEST', row.id); onCellNumberClick(row.id) }}>
{cell.getValue()}
onClick={() => {console.log('TEST', row.id); onCellNumberClick(row.id)}} </button>
>{cell.getValue()}</span>;
}, },
}; };

View File

@ -1,5 +1,4 @@
export function setNewValueToData(data, colId, rowId, value) { export function setNewValueToData(data, colId, rowId, value) {
// 1. Находим целевой объект по rowId(индексы вложенных subRows)
let targetObject = data; let targetObject = data;
if (rowId && rowId.length) { if (rowId && rowId.length) {
const indices = rowId.split(".").map(Number); const indices = rowId.split(".").map(Number);

View File

@ -14,7 +14,7 @@ const TableCard = ({ table, onNavigate }) => {
}; };
return ( return (
<div key={table}> <div key={table.sheet + '_' + table?.direction}>
<Section onClick={handleClick}> <Section onClick={handleClick}>
<SectionStartPart> <SectionStartPart>
<div className="for-img-table"> <div className="for-img-table">

View File

@ -26,7 +26,7 @@ const TablesList = ({
return ( return (
<TableCard <TableCard
key={table.sheet} key={table.sheet+"_"+table?.direction}
onNavigate={() => handleNavigate(path)} onNavigate={() => handleNavigate(path)}
table={table} table={table}
/> />