fixies-3-form #105

Merged
PotapovaA merged 4 commits from fixies-3-form into test 2026-08-19 18:39:11 +03:00
23 changed files with 1028 additions and 1072 deletions

View File

@ -1,5 +1,5 @@
import React, { useMemo, useCallback } from 'react'; import React, { useMemo, useCallback } from 'react';
import { useRealtime } from '../../contexts/RealtimeContext'; import { useCellLock } from '../../contexts/RealtimeContext';
// Константы вне компонента // Константы вне компонента
const BASE_CELL_STYLES = { const BASE_CELL_STYLES = {
@ -136,35 +136,20 @@ const createHighlightedContent = (originalValue, displayValue, searchQueries) =>
}); });
}; };
const CellComponent = ({ const CellComponent = ({ cell, row, column, onClick, globalFilter, columnFilter, backgroundColor, isEditable, isInvalid, vspOptions }) => {
cell,
row,
column,
onClick,
globalFilter,
columnFilter,
backgroundColor,
color,
isEditable,
isUpdating,
onCellNumberClick,
isInvalid,
vspOptions,
}) => {
const { lockedCells } = useRealtime();
const cellKey = `${row.id}_${column.id}`; const cellKey = `${row.id}_${column.id}`;
const isLocked = lockedCells.includes(cellKey); const isLocked = useCellLock(cellKey);
const isVspDropdown = column?.columnDef?.editType === 'vsp_dropdown'; const isVspDropdown = column?.columnDef?.editType === 'vsp_dropdown';
// Мемоизация значения // Мемоизация значения
const { rawValue, displayValue, isNumeric } = useMemo(() => { const { rawValue, displayValue } = useMemo(() => {
const value = cell.getValue(); const value = cell.getValue();
const isNum = value !== null && value !== undefined && value !== '' && !Number.isNaN(Number(value)); const isNum = value !== null && value !== undefined && value !== '' && !Number.isNaN(Number(value));
let display = String(value || ''); let display = String(value || '');
if (isVspDropdown && value != null) { if (isVspDropdown && value != null) {
const vsp = vspOptions?.find(v => v.id === Number(value)); const vsp = vspOptions?.find((v) => v.id === Number(value));
if (vsp) { if (vsp) {
return { rawValue: value, displayValue: vsp.registration_number, isNumeric: false }; return { rawValue: value, displayValue: vsp.registration_number, isNumeric: false };
} }
@ -214,7 +199,7 @@ const CellComponent = ({
return ( return (
// biome-ignore lint/a11y/useKeyWithClickEvents: <explanation> // biome-ignore lint/a11y/useKeyWithClickEvents: <explanation>
<div type="button" className='cell' style={cellStyles} onClick={handleClick}> <div type='button' className='cell' style={cellStyles} onClick={handleClick}>
<span style={CONTAINER_STYLES}>{highlightedContent}</span> <span style={CONTAINER_STYLES}>{highlightedContent}</span>
{lockBadge} {lockBadge}
</div> </div>
@ -232,10 +217,8 @@ const Cell = React.memo(CellComponent, (prevProps, nextProps) => {
prevProps.backgroundColor === nextProps.backgroundColor && prevProps.backgroundColor === nextProps.backgroundColor &&
prevProps.isInvalid === nextProps.isInvalid && prevProps.isInvalid === nextProps.isInvalid &&
prevProps.isEditable === nextProps.isEditable && prevProps.isEditable === nextProps.isEditable &&
prevProps.isUpdating === nextProps.isUpdating &&
prevProps.onCellNumberClick === nextProps.onCellNumberClick &&
prevProps.vspOptions === nextProps.vspOptions prevProps.vspOptions === nextProps.vspOptions
); );
}); });
export default Cell; export default Cell;

View File

@ -1,5 +1,5 @@
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; 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 { calculateFormula, extractFormula, formatNumber, isFormula } from '../../utils/formulaUtils';
import { loadFromStorage, saveToStorage } from '../../utils/localStorageUtils'; import { loadFromStorage, saveToStorage } from '../../utils/localStorageUtils';
@ -30,7 +30,7 @@ const TEXTAREA_STYLES = {
}; };
const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, table }) => { const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, table }) => {
const { endEditing: contextEndEditing } = useRealtime(); const { endEditing: contextEndEditing } = useRealtimeActions();
const storageKey = `formula_${tableId}_${cellId}`; const storageKey = `formula_${tableId}_${cellId}`;
const [state, setState] = useState(() => ({ const [state, setState] = useState(() => ({
@ -91,7 +91,7 @@ const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, ta
const resize = () => { const resize = () => {
el.style.minHeight = '3rem'; el.style.minHeight = '3rem';
el.style.height = '3rem'; el.style.height = '3rem';
el.style.height = el.scrollHeight + 'px'; el.style.height = `${el.scrollHeight}px`;
el.style.maxHeight = '180px'; el.style.maxHeight = '180px';
}; };

View File

@ -1,111 +1,120 @@
import React, { memo, useEffect, useRef, useLayoutEffect, useState, useCallback, useMemo } from 'react'; import { FormControl, MenuItem, Select } from '@mui/material';
import { FormControl, Select, MenuItem } from '@mui/material'; import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useRealtime } from '../../contexts/RealtimeContext'; import { useRealtimeActions, useVspOptions } from '../../contexts/RealtimeContext';
const EDITOR_STYLES = { const EDITOR_STYLES = {
position: 'absolute', position: 'absolute',
zIndex: 12, zIndex: 12,
top: 0, top: 0,
}; };
const VspDropdownEditCell = memo(({ refCell, onChange, disabled, value, table }) => { 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 [selectedValue, setSelectedValue] = useState(() => value ?? '');
const [position, setPosition] = useState(); const [position, setPosition] = useState();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const refFinished = useRef(false); const refFinished = useRef(false);
useEffect(() => { useEffect(() => {
const timer = setTimeout(() => { const timer = setTimeout(() => {
setOpen(true); setOpen(true);
}, 80); }, 80);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, []); }, []);
useLayoutEffect(() => { useLayoutEffect(() => {
if (!refCell?.current) return; if (!refCell?.current) return;
const td = refCell.current.offsetParent; const td = refCell.current.offsetParent;
const tr = td?.offsetParent; const tr = td?.offsetParent;
if (td && tr) { if (td && tr) {
setPosition({ setPosition({
left: td.offsetLeft, left: td.offsetLeft,
width: td.offsetWidth, width: td.offsetWidth,
translateY: tr.style.transform || '', translateY: tr.style.transform || '',
}); });
} }
}, [refCell]); }, [refCell]);
const finishEditing = useCallback(() => { const finishEditing = useCallback(() => {
if (refFinished.current) return; if (refFinished.current) return;
refFinished.current = true; refFinished.current = true;
table.setEditingCell(null); table.setEditingCell(null);
const editingCell = table.getState().editingCell; const editingCell = table.getState().editingCell;
if (editingCell) { if (editingCell) {
contextEndEditing?.(editingCell.row, editingCell.column); contextEndEditing?.(editingCell.row, editingCell.column);
} }
}, [table, contextEndEditing]); }, [table, contextEndEditing]);
const handleSelectChange = useCallback((e) => { const handleSelectChange = useCallback(
const val = e.target.value; (e) => {
if (val == null || val === '') return; const val = e.target.value;
refFinished.current = true; if (val == null || val === '') return;
setSelectedValue(val); refFinished.current = true;
onChange?.(val); setSelectedValue(val);
const editingCell = table.getState().editingCell; onChange?.(val);
if (editingCell) { const editingCell = table.getState().editingCell;
contextEndEditing?.(editingCell.row, editingCell.column); if (editingCell) {
} contextEndEditing?.(editingCell.row, editingCell.column);
}, [onChange, table, contextEndEditing]); }
},
[onChange, table, contextEndEditing],
);
const handleSelectClose = useCallback((_event, reason) => { const handleSelectClose = useCallback(
if (reason === 'selectOption') return; (_event, reason) => {
finishEditing(); if (reason === 'selectOption') return;
}, [finishEditing]); finishEditing();
},
[finishEditing],
);
const editorStyles = useMemo(() => ({ const editorStyles = useMemo(
...EDITOR_STYLES, () => ({
left: position?.left || 0, ...EDITOR_STYLES,
width: position?.width || 0, left: position?.left || 0,
transform: position?.translateY || 0, width: position?.width || 0,
}), [position]); transform: position?.translateY || 0,
}),
[position],
);
return ( return (
<tr> <tr>
<td> <td>
{position && {position && (
<div style={editorStyles}> <div style={editorStyles}>
<FormControl fullWidth size="small"> <FormControl fullWidth size='small'>
<Select <Select
value={selectedValue} value={selectedValue}
onChange={handleSelectChange} onChange={handleSelectChange}
open={open} open={open}
onClose={handleSelectClose} onClose={handleSelectClose}
displayEmpty displayEmpty
disabled={disabled} disabled={disabled}
MenuProps={{ MenuProps={{
anchorOrigin: { vertical: 'bottom', horizontal: 'left' }, anchorOrigin: { vertical: 'bottom', horizontal: 'left' },
transformOrigin: { vertical: 'top', horizontal: 'left' }, transformOrigin: { vertical: 'top', horizontal: 'left' },
}} }}>
> <MenuItem value='' disabled>
<MenuItem value="" disabled> Выберите ВСП
Выберите ВСП </MenuItem>
</MenuItem> {vspOptions.map((option) => (
{vspOptions.map((option) => ( <MenuItem key={option.id} value={option.id}>
<MenuItem key={option.id} value={option.id}> {option.registration_number}
{option.registration_number} </MenuItem>
</MenuItem> ))}
))} </Select>
</Select> </FormControl>
</FormControl> </div>
</div> )}
} </td>
</td> </tr>
</tr> );
);
}); });
VspDropdownEditCell.displayName = 'VspDropdownEditCell'; VspDropdownEditCell.displayName = 'VspDropdownEditCell';

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

@ -1,11 +1,11 @@
import { Box, Divider, IconButton, Stack } from '@mui/material'; 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 { DangerOutlinedButton, PrimaryButton } from '../../common/Buttons/Buttons';
import { HeaderContainer, ModalBackdrop, ModalContainer, ModalTitle } from '../../common/Modal/ModalStyled'; import { HeaderContainer, ModalBackdrop, ModalContainer, ModalTitle } from '../../common/Modal/ModalStyled';
import { StyledTextField } from '../../common/StyledTextField'; import { StyledTextField } from '../../common/StyledTextField';
import { Cancel } from '../../common/icons/icons'; 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(''); const [name, setName] = useState('');
useEffect(() => { useEffect(() => {
@ -36,7 +36,7 @@ export const CreateProgramModal = ({ isOpen, onClose, onCreate, title = '', isSa
return ( return (
<ModalBackdrop isOpen={isOpen} onClick={handleBackdropClick}> <ModalBackdrop isOpen={isOpen} onClick={handleBackdropClick}>
<ModalContainer height={'max-content'}> <ModalContainer height='max-content'>
<HeaderContainer> <HeaderContainer>
<Stack direction='row' spacing={2} sx={{ alignItems: 'center' }}> <Stack direction='row' spacing={2} sx={{ alignItems: 'center' }}>
<ModalTitle>{title}</ModalTitle> <ModalTitle>{title}</ModalTitle>
@ -50,7 +50,7 @@ export const CreateProgramModal = ({ isOpen, onClose, onCreate, title = '', isSa
<Divider sx={{ marginBottom: '0.75rem' }} /> <Divider sx={{ marginBottom: '0.75rem' }} />
{/* <Input */} {/* <Input */}
<StyledTextField label={'Название программы'} onChange={(e) => setName(e.target.value)}></StyledTextField> <StyledTextField label='Название программы' onChange={(e) => setName(e.target.value)} />
<Stack <Stack
direction='row' direction='row'
spacing={0.75} spacing={0.75}
@ -72,3 +72,5 @@ export const CreateProgramModal = ({ isOpen, onClose, onCreate, title = '', isSa
</ModalBackdrop> </ModalBackdrop>
); );
}; };
export const CreateProgramModal = memo(CreateProgramModalComponent);

View File

@ -1,17 +1,17 @@
import { Autocomplete, Box, Divider, FormControl, IconButton, Stack, TextField } from '@mui/material'; 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 { toast } from 'react-toastify';
import { ExpenseItemApi } from '../../../api/expense-item'; import { ExpenseItemApi } from '../../../api/expense-item';
import { DangerOutlinedButton, PrimaryButton } from '../../common/Buttons/Buttons'; import { DangerOutlinedButton, PrimaryButton } from '../../common/Buttons/Buttons';
import { HeaderContainer, ModalBackdrop, ModalContainer, ModalTitle } from '../../common/Modal/ModalStyled'; import { HeaderContainer, ModalBackdrop, ModalContainer, ModalTitle } from '../../common/Modal/ModalStyled';
import { Cancel } from '../../common/icons/icons'; 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 [selectedValue, setSelectedValue] = useState('');
const [expenseItemOptions, setExpenseItemOption] = useState([]); const [expenseItemOptions, setExpenseItemOption] = useState([]);
useEffect(() => { useEffect(() => {
if (formId === null) return; if (!isOpen || formId == null) return;
ExpenseItemApi.getAll({ r_start: true, sheet: sheet }).then((data) => { ExpenseItemApi.getAll({ r_start: true, sheet: sheet }).then((data) => {
if (data.success) { if (data.success) {
setExpenseItemOption(data.result); setExpenseItemOption(data.result);
@ -19,7 +19,7 @@ export const SelectExpenseItemModal = ({ isOpen, onClose, onSelect, formId, titl
} }
toast.error('Ошибка получения статья расходов'); toast.error('Ошибка получения статья расходов');
}); });
}, [formId]); }, [formId, isOpen, sheet]);
useEffect(() => { useEffect(() => {
if (!isOpen) { if (!isOpen) {
@ -49,7 +49,7 @@ export const SelectExpenseItemModal = ({ isOpen, onClose, onSelect, formId, titl
return ( return (
<ModalBackdrop isOpen={isOpen} onClick={handleBackdropClick}> <ModalBackdrop isOpen={isOpen} onClick={handleBackdropClick}>
<ModalContainer height={'max-content'}> <ModalContainer height='max-content'>
<HeaderContainer> <HeaderContainer>
<Stack direction='row' spacing={2} sx={{ alignItems: 'center' }}> <Stack direction='row' spacing={2} sx={{ alignItems: 'center' }}>
<ModalTitle>{title}</ModalTitle> <ModalTitle>{title}</ModalTitle>
@ -72,7 +72,7 @@ export const SelectExpenseItemModal = ({ isOpen, onClose, onSelect, formId, titl
getOptionLabel={(option) => option.name || ''} getOptionLabel={(option) => option.name || ''}
getOptionKey={(option) => option.id} getOptionKey={(option) => option.id}
value={selectedValue} value={selectedValue}
onChange={(event, newValue) => { onChange={(_event, newValue) => {
setSelectedValue(newValue); setSelectedValue(newValue);
}} }}
slotProps={{ slotProps={{
@ -118,3 +118,5 @@ export const SelectExpenseItemModal = ({ isOpen, onClose, onSelect, formId, titl
</ModalBackdrop> </ModalBackdrop>
); );
}; };
export const SelectExpenseItemModal = memo(SelectExpenseItemModalComponent);

View File

@ -1,17 +1,17 @@
import { Box, Divider, FormControl, IconButton, MenuItem, Select, Stack } from '@mui/material'; 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 { toast } from 'react-toastify';
import { DictVspApi } from '../../../api/dict-vsp'; import { DictVspApi } from '../../../api/dict-vsp';
import { DangerOutlinedButton, PrimaryButton } from '../../common/Buttons/Buttons'; import { DangerOutlinedButton, PrimaryButton } from '../../common/Buttons/Buttons';
import { HeaderContainer, ModalBackdrop, ModalContainer, ModalTitle } from '../../common/Modal/ModalStyled'; import { HeaderContainer, ModalBackdrop, ModalContainer, ModalTitle } from '../../common/Modal/ModalStyled';
import { Cancel } from '../../common/icons/icons'; 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 [selectedValue, setSelectedValue] = useState('');
const [vspOptions, setVspOption] = useState([]); const [vspOptions, setVspOption] = useState([]);
useEffect(() => { useEffect(() => {
if (formId === null) return; if (!isOpen || formId == null) return;
DictVspApi.getDropdownVsp({ form_id: formId }).then((data) => { DictVspApi.getDropdownVsp({ form_id: formId }).then((data) => {
if (data.success) { if (data.success) {
setVspOption(data.result); setVspOption(data.result);
@ -19,7 +19,7 @@ export const SelectVspModal = ({ isOpen, onClose, onSelect, formId, title = 'В
} }
toast.error('Ошибка получения ВСП'); toast.error('Ошибка получения ВСП');
}); });
}, [formId]); }, [formId, isOpen]);
useEffect(() => { useEffect(() => {
if (!isOpen) { if (!isOpen) {
@ -49,7 +49,7 @@ export const SelectVspModal = ({ isOpen, onClose, onSelect, formId, title = 'В
return ( return (
<ModalBackdrop isOpen={isOpen} onClick={handleBackdropClick}> <ModalBackdrop isOpen={isOpen} onClick={handleBackdropClick}>
<ModalContainer height={'max-content'}> <ModalContainer height='max-content'>
<HeaderContainer> <HeaderContainer>
<Stack direction='row' spacing={2} sx={{ alignItems: 'center' }}> <Stack direction='row' spacing={2} sx={{ alignItems: 'center' }}>
<ModalTitle>{title}</ModalTitle> <ModalTitle>{title}</ModalTitle>
@ -99,3 +99,5 @@ export const SelectVspModal = ({ isOpen, onClose, onSelect, formId, title = 'В
</ModalBackdrop> </ModalBackdrop>
); );
}; };
export const SelectVspModal = memo(SelectVspModalComponent);

View File

@ -1,70 +1,110 @@
import { MaterialReactTable, useMaterialReactTable } from 'material-react-table';
import React, { useState, useMemo, useRef, useEffect, useCallback, useTransition } from 'react'; import React, { useState, useMemo, useRef, useEffect, useCallback, useTransition } from 'react';
import { createPortal } from 'react-dom'; 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 { 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 { 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 { FORM_TYPE_OPTIONS } from '../../constants/constants';
import { debounce } from '@mui/material'; import { debounce } from '@mui/material';
import { toast } from 'react-toastify';
import { CreateProgramModal } from './Modals/CreateProgramProjectModal'; 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 { DictVspApi } from '../../api/dict-vsp';
import { useAuth } from '../../app/context/AuthProvider';
import { FormsNoCanAddRow } from './constants/formConfig';
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 { data, setData, columnsCurStage, isLoading: isTableLoading, editingCells: dataEditingCells } = useRealtimeData(formId, sheetName, direction, formType, year); const { user } = useAuth();
const userRoleId = user?.role_id;
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(data, columnsConfig?.columns, formType, sheetName);
data,
columnsConfig?.columns,
formType,
sheetName,
);
const [expanded, setExpanded] = useState(true); const [expanded, setExpanded] = useState(true);
const [showOnlyDepth2, setShowOnlyDepth2] = useState(false); const [showOnlyDepth2, setShowOnlyDepth2] = useState(false);
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(() => FormsNoCanAddRow[formType]?.includes(sheetName) ?? false, [formType, sheetName]);
return (FormsNoCanAddRow[formType].includes(sheetName));
})
const isVspAdditionRow = useMemo(() => { const isVspAdditionRow = useMemo(() => {
if (!formType || !sheetName) return false; if (!formType || !sheetName) return false;
@ -78,21 +118,19 @@ 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) => { () =>
startTransition(() => { debounce((value) => {
setGlobalFilter(value); startTransition(() => {
}); 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,
@ -100,7 +138,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
addProgram: contextAddProgram, addProgram: contextAddProgram,
addProject: contextAddProject, addProject: contextAddProject,
setVspOptions, setVspOptions,
} = useRealtime(); } = useRealtimeActions();
const { const {
columnSizing, columnSizing,
@ -112,73 +150,76 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
handleUnpinColumn, handleUnpinColumn,
handleSetColumnWidth, handleSetColumnWidth,
handleToggleColumnVisibility, handleToggleColumnVisibility,
handleSetColumnsVisibility,
} = useColumnSettings(`${formId}_${formType}_${sheetName}_${direction}`); } = useColumnSettings(`${formId}_${formType}_${sheetName}_${direction}`);
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();
useEffect(() => { useEffect(() => {
rowVirtualizerRef.current?.measure?.(); rowVirtualizerRef.current?.measure?.();
columnVirtualizerRef.current?.measure?.();
}, [sizeMult]); }, [sizeMult]);
const configCache = useRef(new Map());
useEffect(() => { useEffect(() => {
if (!formType || !sheetName) return; if (!formType || !sheetName) return;
let isCancelled = false;
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); if (!isCancelled) 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);
setColumnsConfig({ columns: [], colors: {} }); if (!isCancelled) setColumnsConfig({ columns: [], colors: {} });
} }
}; };
loadConfig(); loadConfig();
return () => {
isCancelled = true;
};
}, [formType, sheetName]); }, [formType, sheetName]);
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;
let isCancelled = false;
DictVspApi.getDropdownVsp({ form_id: formId }).then(data => { DictVspApi.getDropdownVsp({ form_id: formId }).then((data) => {
if (data.success) { if (!isCancelled && data.success) {
setVspOptions(data.result); setVspOptions(data.result);
} }
}); });
return () => {
isCancelled = true;
};
}, [formId, columnsConfig, setVspOptions]); }, [formId, columnsConfig, setVspOptions]);
const handleUpdateCell = useCallback(
const handleUpdateCell = useCallback(async (row, column, value) => { (row, column, value) => {
return await contextUpdateCell(row, column, value); return contextUpdateCell(row, column, value);
}, [contextUpdateCell]); },
[contextUpdateCell],
);
const handleClickRowCell = useCallback((rowId) => { const handleClickRowCell = useCallback((rowId) => {
setRowSelection((prev) => ({ setRowSelection((prev) => ({
[rowId]: !prev[rowId], [rowId]: !prev[rowId],
})); }));
}, []) }, []);
const handleColumnSelect = useCallback((columnId) => { const handleColumnSelect = useCallback((columnId) => {
setSelectedColumnId((prev) => { setSelectedColumnId((prev) => {
@ -215,7 +256,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
EditCell, EditCell,
columnsConfig, columnsConfig,
columnsCurStage, columnsCurStage,
onCellUpdate: handleUpdateCell, userRoleId,
onCellNumberClick: handleClickRowCell, onCellNumberClick: handleClickRowCell,
onCellUpdateError: handleCellUpdateError, onCellUpdateError: handleCellUpdateError,
isCellInvalid, isCellInvalid,
@ -224,33 +265,40 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
console.error('Error creating columns:', error); console.error('Error creating columns:', error);
return []; return [];
} }
}, [ }, [columnsConfig, columnsCurStage, userRoleId, handleCellUpdateError, handleClickRowCell, handleUpdateCell, isCellInvalid]);
columnsConfig,
columnsCurStage,
handleCellUpdateError,
handleClickRowCell,
handleUpdateCell,
isCellInvalid,
]);
const selectedColumnIdRef = useRef(selectedColumnId); // MRT запоминает индексы pinned-колонок до завершения асинхронной
// загрузки конфигурации. Новая ссылка на columnPinning заставляет
// виртуализатор пересчитать эти индексы уже с полным набором колонок.
useEffect(() => {
if (!columnsConfig?.columns?.length) return;
setColumnPinning((currentPinning) => ({
left: [...(currentPinning.left || [])],
right: [...(currentPinning.right || [])],
}));
}, [columnsConfig, setColumnPinning]);
useEffect(() => { useEffect(() => {
selectedColumnIdRef.current = selectedColumnId; if (!columns.length) return;
}, [selectedColumnId]);
const tableMeta = useMemo(() => ({ const animationFrameId = requestAnimationFrame(() => {
updateCell: handleUpdateCell, columnVirtualizerRef.current?.measure?.();
getSelectedColumnId: () => selectedColumnIdRef.current, });
}), [handleUpdateCell]);
const ROW_VIRTUALIZER_OPTIONS = { return () => cancelAnimationFrame(animationFrameId);
overscan: 5, }, [columns, columnPinning, columnSizing, columnVisibility]);
scrollPaddingStart: 0,
scrollPaddingEnd: 0, const selectedColumnIdRef = useRef(selectedColumnId);
estimateSize: () => TABLE_ROW_HEIGHT, selectedColumnIdRef.current = selectedColumnId;
measureElement: (el) => el?.offsetHeight || TABLE_ROW_HEIGHT,
}; const tableMeta = useMemo(
() => ({
updateCell: handleUpdateCell,
getSelectedColumnId: () => selectedColumnIdRef.current,
}),
[handleUpdateCell],
);
const createTableConfig = ({ const createTableConfig = ({
columns, columns,
@ -276,27 +324,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,
@ -311,7 +346,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
showColumnFilters, showColumnFilters,
rowSelection, rowSelection,
editingCell, editingCell,
expanded expanded,
}, },
initialState: { initialState: {
expanded: true, expanded: true,
@ -333,7 +368,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
minHeight: '1px', minHeight: '1px',
maxHeight: '1px', maxHeight: '1px',
visibility: 'hidden', visibility: 'hidden',
} },
}, },
}, },
muiTablePaperProps: getTablePaperStyles(), muiTablePaperProps: getTablePaperStyles(),
@ -352,68 +387,74 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
}, },
}); });
const tableConfig = useMemo(() => createTableConfig({ const tableConfig = useMemo(
columns, () =>
data, createTableConfig({
columnSizing, columns,
columnPinning, data,
columnVisibility, columnSizing,
expanded, columnPinning,
globalFilter, columnVisibility,
showColumnFilters, expanded,
rowSelection, globalFilter,
containerRef, showColumnFilters,
tableMeta, rowSelection,
setColumnSizing, containerRef,
setColumnPinning, tableMeta,
setExpanded, setColumnSizing,
editingCell, setColumnPinning,
contextStartEditing, setExpanded,
}), [ editingCell,
columns, contextStartEditing,
data, }),
columnSizing, [
columnPinning, columns,
columnVisibility, data,
globalFilter, columnSizing,
showColumnFilters, columnPinning,
rowSelection, columnVisibility,
containerRef, globalFilter,
tableMeta, showColumnFilters,
setColumnSizing, rowSelection,
setColumnPinning, containerRef,
editingCell, tableMeta,
expanded, setColumnSizing,
]); setColumnPinning,
editingCell,
expanded,
],
);
const table = useMaterialReactTable(tableConfig); const table = useMaterialReactTable(tableConfig);
const isLoadingData = columns.length > 0;
const handleNavigateToColumn = useCallback((columnId) => { const handleNavigateToColumn = useCallback(
if (!columnId) return; (columnId) => {
if (!columnId) return;
setSelectedColumnId(columnId); setSelectedColumnId(columnId);
const container = containerRef.current; const container = containerRef.current;
if (!container) return; if (!container) return;
const pinnedIds = new Set(columnPinning?.left || []); const pinnedIds = new Set(columnPinning?.left || []);
if (pinnedIds.has(columnId)) return; if (pinnedIds.has(columnId)) return;
const centerColumns = table const centerColumns = table.getVisibleLeafColumns().filter((column) => !pinnedIds.has(column.id));
.getVisibleLeafColumns()
.filter((column) => !pinnedIds.has(column.id));
let offset = 0; let offset = 0;
for (const column of centerColumns) { for (const column of centerColumns) {
if (column.id === columnId) break; if (column.id === columnId) break;
offset += column.getSize(); offset += column.getSize();
} }
container.scrollTo({ container.scrollTo({
left: Math.max(0, offset - 40), left: Math.max(0, offset - 40),
behavior: 'smooth', behavior: 'smooth',
}); });
}, [columnPinning, containerRef, table]); },
[columnPinning, containerRef, table],
);
useEffect(() => { useEffect(() => {
if (!dataEditingCells?.line_id) { if (!dataEditingCells?.line_id) {
@ -422,99 +463,90 @@ 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((c) => c.column.id === dataEditingCells.column);
c => c.column.id === dataEditingCells.column
);
if (cell) setEditingCell(cell); if (cell) setEditingCell(cell);
} catch (error) { } catch (error) {
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]), [table]);
useEffect(() => {
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(
contextAddRow({ vsp_id: vsp_id }); (vsp_id) => {
}, []); contextAddRow({ vsp_id });
},
[contextAddRow],
);
const handleAddExpenseItemRow = useCallback((expense_item) => { const handleAddExpenseItemRow = useCallback(
const allRows = table.getRowModel().rows; (expense_item) => {
const selectedRows = allRows.filter(row => rowSelection[row.id]); const row = getSelectedRow();
const row = selectedRows[0]; if (!row) return;
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(
contextAddProgram({ name: name }); (name) => {
}, []); contextAddProgram({ name });
},
[contextAddProgram],
);
const handleAddProjectRow = useCallback((name) => { const handleAddProjectRow = useCallback(
const allRows = table.getRowModel().rows; (name) => {
const selectedRows = allRows.filter(row => rowSelection[row.id]); const row = getSelectedRow();
const row = selectedRows[0]; if (!row) return;
contextAddProject({ name: name, program_id: row.original.data.header.program_id }); contextAddProject({ name, program_id: row.original.data.header.program_id });
}, [rowSelection]); },
[contextAddProject, getSelectedRow],
);
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();
} }
@ -525,45 +557,39 @@ 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;
}
}; };
}, []); }, []);
const handleToggleDepthVisibility = useCallback(() => { const handleToggleDepthVisibility = useCallback(() => {
setShowOnlyDepth2(prev => { setShowOnlyDepth2((prev) => {
const newState = !prev; const newState = !prev;
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) {
@ -588,22 +614,22 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
<SettingsPanel <SettingsPanel
selectedColumnId={selectedColumnId} selectedColumnId={selectedColumnId}
columnPinning={columnPinning} columnPinning={columnPinning}
table={table}
columns={columns} columns={columns}
onPinColumn={handlePinColumn} onPinColumn={handlePinColumn}
onUnpinColumn={handleUnpinColumn} onUnpinColumn={handleUnpinColumn}
onToggleColumnVisibility={handleToggleColumnVisibility} onToggleColumnVisibility={handleToggleColumnVisibility}
onSetColumnsVisibility={handleSetColumnsVisibility}
columnVisibility={columnVisibility} columnVisibility={columnVisibility}
columnsCurStage={columnsCurStage} columnsCurStage={columnsCurStage}
onChangeSizeMult={setSizeMult} onChangeSizeMult={setSizeMult}
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}
onDeleteRow={handleDeleteRow} onDeleteRow={handleDeleteRow}
isLoadingData={isLoadingData}
formId={formId} formId={formId}
sheetName={sheetName} sheetName={sheetName}
direction={direction} direction={direction}
@ -618,15 +644,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
@ -639,17 +657,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>
)} )}
@ -658,7 +666,12 @@ 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}
columnFilters={table.getState().columnFilters}
showColumnFilters={showColumnFilters}
selectedColumnId={selectedColumnId} selectedColumnId={selectedColumnId}
onColumnSelect={handleColumnSelect} onColumnSelect={handleColumnSelect}
onChangeWidth={handleSetColumnWidth} onChangeWidth={handleSetColumnWidth}
@ -671,24 +684,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,7 +1,8 @@
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 { stageColumnsHiddenFromPicker } from '../../Stages/constants';
import { ExportDefaultButton } from '../../common/Buttons/ButtonsActions'; import { ExportDefaultButton } from '../../common/Buttons/ButtonsActions';
import CollapsibleTree from '../../common/CollapsibleTree'; import CollapsibleTree from '../../common/CollapsibleTree';
import SearchComponent from '../../common/SearchComponent'; import SearchComponent from '../../common/SearchComponent';
@ -19,37 +20,39 @@ import {
Search, Search,
VisibleExpenseItems, VisibleExpenseItems,
} from '../../common/icons/icons'; } from '../../common/icons/icons';
import { collectLeafColumns } from '../utils/columnUtils';
import { GroupByObject } from './GroupByObject/GroupByObject'; import { GroupByObject } from './GroupByObject/GroupByObject';
import { Panel } from './SettingPanel.style'; import { Panel } from './SettingPanel.style';
import ValidationErrorsAlert from './ValidationErrorsAlert'; import ValidationErrorsAlert from './ValidationErrorsAlert';
import ZoomSlider from './ZoomSlider'; import ZoomSlider from './ZoomSlider';
import { collectLeafColumns } from '../utils/columnUtils';
import { stageColumnsHiddenFromPicker } from '../../Stages/constants';
const controlSx = { const controlSx = {
minHeight: '2.5rem', minHeight: '2.5rem',
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,
table,
columns, columns,
onPinColumn, onPinColumn,
onUnpinColumn, onUnpinColumn,
onToggleColumnVisibility, onToggleColumnVisibility,
onSetColumnsVisibility,
columnVisibility, columnVisibility,
columnsCurStage, columnsCurStage,
onChangeSizeMult, onChangeSizeMult,
onGlobalFilterChange, onGlobalFilterChange,
sizeMult, sizeMult,
onChangeShowColumnFilters, onChangeShowColumnFilters,
showColumnFilters,
onAddRow, onAddRow,
onAddProgram, onAddProgram,
onAddProject, onAddProject,
onDeleteRow, onDeleteRow,
isLoadingData,
formId, formId,
sheetName, sheetName,
direction, direction,
@ -61,54 +64,39 @@ 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) {
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]);
//TO DO: сделать
const { addColorForCells } = {};
const handleChangeShowColumnFilters = () => {
const show = table.getState().showColumnFilters;
setShowColumnFilters(!show);
onChangeShowColumnFilters(!show);
};
const handleExport = 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) return;
setIsExporting(true);
await exportSheet(formId, sheetName, direction);
setIsExporting(false);
};
const leafColumns = useMemo(() => collectLeafColumns({ columns }), [columns]);
const stageColumnIds = useMemo(
() => new Set([...stageColumnsHiddenFromPicker, ...(columnsCurStage || [])]),
[columnsCurStage],
); );
const selectedColumnIds = useMemo(() => Object.keys(columnVisibility).filter((key) => !columnVisibility[key]), [columnVisibility]);
const handleChangeShowColumnFilters = useCallback(() => {
onChangeShowColumnFilters(!showColumnFilters);
}, [onChangeShowColumnFilters, showColumnFilters]);
const handleExport = useCallback(async () => {
if (isExporting) return;
if (!formId || !sheetName || (formType === 'PROJECT' && !year)) 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 || [])]), [columnsCurStage]);
const showOnlyCurrentStageColumns = useMemo(() => { const showOnlyCurrentStageColumns = useMemo(() => {
if (!columnsCurStage?.length || !leafColumns.length) return false; if (!columnsCurStage?.length || !leafColumns.length) return false;
return leafColumns.every((column) => return leafColumns.every((column) =>
@ -121,10 +109,20 @@ const SettingPanel = ({
console.warn('Нет столбцов для текущего этапа'); console.warn('Нет столбцов для текущего этапа');
return; return;
} }
const visibilityByColumnId = {};
for (const column of leafColumns) { 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]);
const handleSetAllColumnsVisibility = useCallback(
(columnIds, isVisible) => {
const visibilityByColumnId = Object.fromEntries(columnIds.map((columnId) => [columnId, isVisible]));
onSetColumnsVisibility(visibilityByColumnId);
},
[onSetColumnsVisibility],
);
return ( return (
<> <>
@ -140,19 +138,12 @@ 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>
</GroupByObject> </GroupByObject>
<Divider orientation='vertical' flexItem /> <Divider orientation='vertical' flexItem />
<GroupByObject title='Строки'> <GroupByObject title='Строки'>
{!isFormNoCanAddRow && ( {!isFormNoCanAddRow && (
@ -200,7 +191,12 @@ const SettingPanel = ({
<Divider orientation='vertical' flexItem /> <Divider orientation='vertical' flexItem />
<GroupByObject title='Столбцы'> <GroupByObject title='Столбцы'>
{columns ? ( {columns ? (
<CollapsibleTree columnTree={columns} onSelectNode={onToggleColumnVisibility} selectedIds={selectedColumnIds} /> <CollapsibleTree
columnTree={columns}
onSelectNode={onToggleColumnVisibility}
onSelectAllNodes={handleSetAllColumnsVisibility}
selectedIds={selectedColumnIds}
/>
) : ( ) : (
'' ''
)} )}
@ -211,8 +207,7 @@ const SettingPanel = ({
color='success' color='success'
data-active={showOnlyCurrentStageColumns} data-active={showOnlyCurrentStageColumns}
onClick={handleToggleCurrentStageColumns} onClick={handleToggleCurrentStageColumns}
disabled={!columnsCurStage || columnsCurStage.length === 0} disabled={!columnsCurStage || columnsCurStage.length === 0}>
>
{showOnlyCurrentStageColumns ? <AllStageColumns /> : <CurrentStageColumns />} {showOnlyCurrentStageColumns ? <AllStageColumns /> : <CurrentStageColumns />}
</IconButton> </IconButton>
</Tooltip> </Tooltip>
@ -223,7 +218,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,14 +238,14 @@ 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>
<ValidationErrorsAlert errors={validationErrors} onNavigateToColumn={onNavigateToColumn} /> <ValidationErrorsAlert errors={validationErrors} onNavigateToColumn={onNavigateToColumn} />
</Panel > </Panel>
</> </>
); );
}; };
export default SettingPanel; export default memo(SettingPanel);

View File

@ -1,7 +1,7 @@
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import WarningAmberRoundedIcon from '@mui/icons-material/WarningAmberRounded'; import WarningAmberRoundedIcon from '@mui/icons-material/WarningAmberRounded';
import { Button, Menu, MenuItem, Stack } from '@mui/material'; import { Button, Menu, MenuItem, Stack } from '@mui/material';
import { useEffect, useState } from 'react'; import { memo, useEffect, useState } from 'react';
const warningColors = { const warningColors = {
bg: '#fff7e6', bg: '#fff7e6',
@ -115,4 +115,4 @@ const ValidationErrorsAlert = ({ errors = [], onNavigateToColumn }) => {
); );
}; };
export default ValidationErrorsAlert; export default memo(ValidationErrorsAlert);

View File

@ -1,32 +1,32 @@
import { Box, Slider } from '@mui/material'; 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 ZoomSlider = ({ onChange, width = 200, curScale }) => {
const [scale, setScale] = useState(curScale); const handleChange = (_event, newValue) => {
const handleChange = (event, newValue) => {
setScale(newValue);
if (onChange) onChange(newValue); if (onChange) onChange(newValue);
}; };
return ( return (
<Box sx={{ width, height: '2.5rem', display: 'flex', alignItems: 'center', gap: 2 }}> <Box sx={width === 200 ? sliderContainerSx : { ...sliderContainerSx, width }}>
<Slider <Slider
value={scale} value={curScale}
onChange={handleChange} onChange={handleChange}
min={0.5} min={0.5}
max={2} max={2}
step={0.01} step={0.01}
valueLabelDisplay='auto' valueLabelDisplay='auto'
valueLabelFormat={(value) => `${Math.round(value * 100)}%`} valueLabelFormat={(value) => `${Math.round(value * 100)}%`}
marks={[ marks={zoomMarks}
{ value: 0.5, label: '50%' },
{ value: 1, label: '100%' },
{ value: 2, label: '200%' },
]}
/> />
</Box> </Box>
); );
}; };
export default ZoomSlider; export default memo(ZoomSlider);

View File

@ -1,75 +1,18 @@
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 [...table.getLeftVisibleLeafColumns(), ...table.getCenterVisibleLeafColumns(), ...table.getRightVisibleLeafColumns()];
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;
});
}; };
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 +45,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 +75,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 +104,12 @@ 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 +125,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 +161,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 +173,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 }) => {
@ -285,16 +193,15 @@ const FilterRow = ({ table }) => {
backgroundColor: 'white', backgroundColor: 'white',
}}> }}>
{filteredColumns.map((column) => ( {filteredColumns.map((column) => (
<FilterCell key={`filter-${column.id}`} column={column} table={table} /> <FilterCell key={`filter-${column.id}`} column={column} />
))} ))}
</div> </div>
); );
}; };
// Компонент ячейки фильтра // Компонент ячейки фильтра
const FilterCell = ({ column, table }) => { const FilterCell = ({ column }) => {
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 +303,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 +312,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 +323,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 +343,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);

View File

@ -279,7 +279,7 @@ export const config = {
accessorKey: 'otchet_ob_ispolnenii_tekuschikh_raskhodov_v_i_kvartale_korrektirovka_limita', accessorKey: 'otchet_ob_ispolnenii_tekuschikh_raskhodov_v_i_kvartale_korrektirovka_limita',
columns: [ columns: [
{ {
header: 'По статьям сметы *', header: 'По статьям сметы*',
accessorKey: 'data.q1.adj_by_items', accessorKey: 'data.q1.adj_by_items',
columnLetter: 'E', columnLetter: 'E',
size: 180, size: 180,
@ -292,7 +292,7 @@ export const config = {
}, },
}, },
{ {
header: 'Увеличение сметы **', header: 'Увеличение сметы**',
accessorKey: 'data.q1.adj_increase', accessorKey: 'data.q1.adj_increase',
columnLetter: 'F', columnLetter: 'F',
size: 200, size: 200,
@ -368,7 +368,7 @@ export const config = {
}, },
}, },
{ {
header: 'За', header: 'За март',
accessorKey: 'data.q1.m3', accessorKey: 'data.q1.m3',
columnLetter: 'J', columnLetter: 'J',
size: 150, size: 150,
@ -462,7 +462,7 @@ export const config = {
accessorKey: 'otchet_ob_ispolnenii_tekuschikh_raskhodov_v_ii_kvartale_korrektirovka_limita', accessorKey: 'otchet_ob_ispolnenii_tekuschikh_raskhodov_v_ii_kvartale_korrektirovka_limita',
columns: [ columns: [
{ {
header: 'По статьям сметы *', header: 'По статьям сметы*',
accessorKey: 'data.q2.adj_by_items', accessorKey: 'data.q2.adj_by_items',
columnLetter: 'O', columnLetter: 'O',
size: 180, size: 180,
@ -475,7 +475,7 @@ export const config = {
}, },
}, },
{ {
header: 'Увеличение сметы **', header: 'Увеличение сметы**',
accessorKey: 'data.q2.adj_increase', accessorKey: 'data.q2.adj_increase',
columnLetter: 'P', columnLetter: 'P',
size: 200, size: 200,
@ -551,7 +551,7 @@ export const config = {
}, },
}, },
{ {
header: 'За', header: 'За июнь',
accessorKey: 'data.q2.m3', accessorKey: 'data.q2.m3',
columnLetter: 'T', columnLetter: 'T',
size: 150, size: 150,
@ -645,7 +645,7 @@ export const config = {
accessorKey: 'otchet_ob_ispolnenii_tekuschikh_raskhodov_v_iii_kvartale_korrektirovka_limita', accessorKey: 'otchet_ob_ispolnenii_tekuschikh_raskhodov_v_iii_kvartale_korrektirovka_limita',
columns: [ columns: [
{ {
header: 'По статьям сметы *', header: 'По статьям сметы*',
accessorKey: 'data.q3.adj_by_items', accessorKey: 'data.q3.adj_by_items',
columnLetter: 'Y', columnLetter: 'Y',
size: 180, size: 180,
@ -658,7 +658,7 @@ export const config = {
}, },
}, },
{ {
header: 'Увеличение сметы **', header: 'Увеличение сметы**',
accessorKey: 'data.q3.adj_increase', accessorKey: 'data.q3.adj_increase',
columnLetter: 'Z', columnLetter: 'Z',
size: 200, size: 200,
@ -734,7 +734,7 @@ export const config = {
}, },
}, },
{ {
header: 'За', header: 'За сентябрь',
accessorKey: 'data.q3.m3', accessorKey: 'data.q3.m3',
columnLetter: 'AD', columnLetter: 'AD',
size: 150, size: 150,
@ -828,7 +828,7 @@ export const config = {
accessorKey: 'otchet_ob_ispolnenii_tekuschikh_raskhodov_v_iv_kvartale_korrektirovka_limita', accessorKey: 'otchet_ob_ispolnenii_tekuschikh_raskhodov_v_iv_kvartale_korrektirovka_limita',
columns: [ columns: [
{ {
header: 'По статьям сметы *', header: 'По статьям сметы*',
accessorKey: 'data.q4.adj_by_items', accessorKey: 'data.q4.adj_by_items',
columnLetter: 'AI', columnLetter: 'AI',
size: 180, size: 180,
@ -841,7 +841,7 @@ export const config = {
}, },
}, },
{ {
header: 'Увеличение сметы **', header: 'Увеличение сметы**',
accessorKey: 'data.q4.adj_increase', accessorKey: 'data.q4.adj_increase',
columnLetter: 'AJ', columnLetter: 'AJ',
size: 200, size: 200,
@ -917,7 +917,7 @@ export const config = {
}, },
}, },
{ {
header: 'За', header: 'За декабрь',
accessorKey: 'data.q4.m3', accessorKey: 'data.q4.m3',
columnLetter: 'AN', columnLetter: 'AN',
size: 150, size: 150,

View File

@ -28,6 +28,10 @@ export const BASE_TABLE_CONFIG = {
defaultColumn: { filterFn: 'contains' }, defaultColumn: { filterFn: 'contains' },
columnResizeMode: 'onChange', columnResizeMode: 'onChange',
localization: { localization: {
expand: 'Раскрыть',
expandAll: 'Раскрыть все',
collapse: 'Свернуть',
collapseAll: 'Свернуть все',
noRecordsToDisplay: <span>Нет данных для отображения</span>, noRecordsToDisplay: <span>Нет данных для отображения</span>,
}, },
}; };

View File

@ -1,116 +1,162 @@
import { import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
createContext, import { toast } from 'react-toastify';
useCallback, import { useWebSocket } from '../hooks/useWebSocket';
useContext, import { getRowId } from '../utils/rowUtils';
useEffect,
useRef,
useState,
} 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);
export const useRealtime = () => { const useRequiredContext = (context, hookName) => {
const context = useContext(RealtimeContext); const value = useContext(context);
if (!context) { if (value === null) {
throw new Error("useRealtime must be used within RealtimeProvider"); throw new Error(`${hookName} must be used within RealtimeProvider`);
} }
return context; return value;
}; };
export const RealtimeProvider = ({ const createLockedCellsStore = () => {
children, let cells = new Set();
formId, const listenersByCell = new Map();
sheetName,
direction, const subscribeCell = (cellKey, listener) => {
year, const listeners = listenersByCell.get(cellKey) || new Set();
userId, listeners.add(listener);
isProject = false, 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 nextCells = new Set(resolvedValue || []);
const changedCells = new Set([
...[...cells].filter((cellKey) => !nextCells.has(cellKey)),
...[...nextCells].filter((cellKey) => !cells.has(cellKey)),
]);
if (changedCells.size === 0) return;
cells = nextCells;
changedCells.forEach((cellKey) => {
listenersByCell.get(cellKey)?.forEach((listener) => listener());
});
};
return {
subscribeCell,
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 subscribe = useCallback((listener) => store.subscribeCell(cellKey, listener), [cellKey, store]);
const getSnapshot = useCallback(() => store.hasCell(cellKey), [cellKey, store]);
return useSyncExternalStore(subscribe, getSnapshot, () => false);
};
export const useRealtime = () => {
const actions = useRealtimeActions();
const connection = useRealtimeConnection();
const vspOptions = useVspOptions();
return { ...actions, ...connection, vspOptions };
};
export const RealtimeProvider = ({ children, formId, sheetName, direction, year, isProject = false }) => {
// Формируем URL (кастомные секреты на фронте пока недоступны, делаем через REACT_APP_ROOT_PATH) // Формируем URL (кастомные секреты на фронте пока недоступны, делаем через REACT_APP_ROOT_PATH)
const wsUrl = `${( const wsUrl = `${(
process.env.REACT_APP_API_URL || process.env.REACT_APP_API_URL || process.env.REACT_APP_API_URL || process.env.REACT_APP_ROOT_PATH || 'ws://localhost:8000'
process.env.REACT_APP_API_URL || ).replace(/^http/, 'ws')}${
process.env.REACT_APP_ROOT_PATH ||
"ws://localhost:8000"
).replace(/^http/, "ws")}${
!isProject !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}` : `/api/v1/ws/projects/${formId}/report/${year}/${sheetName}`
}`; }`;
//для ячеек которые редактируются другими пользователями
const [lockedCells, setLockedCells] = useState([]);
const [vspOptions, setVspOptions] = useState([]); const [vspOptions, setVspOptions] = useState([]);
const lockedCellsStoreRef = useRef(null);
if (lockedCellsStoreRef.current === null) {
lockedCellsStoreRef.current = createLockedCellsStore();
}
const lockedCellsStore = lockedCellsStoreRef.current;
const handleMessage = useCallback((data) => { const handleMessage = useCallback((data) => {
// Обработка ошибок // Обработка ошибок
if (data.error) { if (data.error) {
console.log(data.error.message); console.log(data.error.message);
toast.error(`Server error:${data.error.message}` || ""); toast.error(`Server error:${data.error.message}` || '');
onErrorRef.current?.(data.error); onErrorRef.current?.(data.error);
return; return;
} }
switch (data.event) { switch (data.event) {
case "cell_edit_start": case 'cell_edit_start':
console.log("Cell edit started:", data); console.log('Cell edit started:', data);
if (onCellEditStartRef.current) { if (onCellEditStartRef.current) {
onCellEditStartRef.current(data); onCellEditStartRef.current(data);
} }
break; break;
case "cell_edit_end": case 'cell_edit_end':
console.log("Cell edit ended:", data); console.log('Cell edit ended:', data);
if (onCellEditEndRef.current) { if (onCellEditEndRef.current) {
onCellEditEndRef.current(data); onCellEditEndRef.current(data);
} }
break; break;
case "cell_updated": case 'cell_updated':
if (data.result && onCellUpdateRef.current) { if (data.result && onCellUpdateRef.current) {
const updatedCells = Array.isArray(data.result) const updatedCells = Array.isArray(data.result) ? data.result : [data.result];
? data.result
: [data.result];
onCellUpdateRef.current(updatedCells); onCellUpdateRef.current(updatedCells);
} }
break; break;
case "row_added": case 'row_added':
if (data.result && onRowAddRef.current) { if (data.result && onRowAddRef.current) {
onRowAddRef.current(data); onRowAddRef.current(data);
} }
break; break;
case "row_deleted": case 'row_deleted':
if (data.result && onRowDeleteRef.current) { if (data.result && onRowDeleteRef.current) {
onRowDeleteRef.current(data); onRowDeleteRef.current(data);
} }
break; break;
case "program_added": case 'program_added':
console.log("Program added:", data); console.log('Program added:', data);
if (data.result && onProgramAddRef.current) { if (data.result && onProgramAddRef.current) {
onProgramAddRef.current(data); onProgramAddRef.current(data);
} }
break; break;
case "project_added": case 'project_added':
console.log("Project added:", data); console.log('Project added:', data);
if (data.result && onProjectAddRef.current) { if (data.result && onProjectAddRef.current) {
onProjectAddRef.current(data); onProjectAddRef.current(data);
} }
break; break;
default: default:
console.log("Unknown event:", data.event); console.log('Unknown event:', data.event);
} }
}, []); }, []);
const { isConnectedRef, isConnected, sendMessage, disconnect, lastError } = const { isConnectedRef, isConnected, sendMessage } = useWebSocket(wsUrl, handleMessage);
useWebSocket(wsUrl, handleMessage);
const onCellUpdateRef = useRef(null); const onCellUpdateRef = useRef(null);
const onRowAddRef = useRef(null); const onRowAddRef = useRef(null);
@ -122,21 +168,20 @@ export const RealtimeProvider = ({
const onCellEditStartRef = useRef(null); const onCellEditStartRef = useRef(null);
const onCellEditEndRef = useRef(null); const onCellEditEndRef = useRef(null);
const authAttemptedRef = useRef(false); const authAttemptedRef = useRef(false);
const reconnectTimerRef = useRef(null);
// Функция для получения токена из localStorage // Функция для получения токена из localStorage
const getAccessToken = useCallback(() => { const getAccessToken = useCallback(() => {
try { try {
const token = localStorage.getItem("access_token"); const token = localStorage.getItem('access_token');
if (!token) { if (!token) {
console.warn("No access token found in localStorage"); console.warn('No access token found in localStorage');
return null; return null;
} }
return token; return token;
} catch (error) { } catch (error) {
console.error("Error reading token from localStorage:", error); console.error('Error reading token from localStorage:', error);
return null; return null;
} }
}, []); }, []);
@ -144,13 +189,13 @@ export const RealtimeProvider = ({
const sendLogin = useCallback(() => { const sendLogin = useCallback(() => {
const token = getAccessToken(); const token = getAccessToken();
if (!token) { if (!token) {
console.error("Cannot login: no token available"); console.error('Cannot login: no token available');
onErrorRef.current?.("Токен авторизации не найден"); onErrorRef.current?.('Токен авторизации не найден');
return false; return false;
} }
const loginMessage = { const loginMessage = {
event: "user_login", event: 'user_login',
data: { token }, data: { token },
}; };
@ -160,10 +205,11 @@ export const RealtimeProvider = ({
useEffect(() => { useEffect(() => {
if (isConnected && !authAttemptedRef.current) { if (isConnected && !authAttemptedRef.current) {
authAttemptedRef.current = true; authAttemptedRef.current = true;
console.log("WebSocket connected, sending login..."); console.log('WebSocket connected, sending login...');
setTimeout(() => { const timeoutId = setTimeout(() => {
sendLogin(); sendLogin();
}, 100); }, 100);
return () => clearTimeout(timeoutId);
} }
}, [isConnected, sendLogin]); }, [isConnected, sendLogin]);
@ -173,27 +219,29 @@ export const RealtimeProvider = ({
} }
}, [isConnected]); }, [isConnected]);
const addCommonField = (message) => { const addCommonField = useCallback(
if (isProject) { (message) => {
message.report_type = sheetName; if (isProject) {
message.project_id = formId; message.report_type = sheetName;
message.year = year; message.project_id = formId;
} else { message.year = year;
message.sheet = sheetName; } else {
message.form_id = formId; message.sheet = sheetName;
message.direction = direction; message.form_id = formId;
} message.direction = direction;
return message; }
}; return message;
},
[direction, formId, isProject, sheetName, year],
);
// Функция для начала редактирования ячейки // Функция для начала редактирования ячейки
const startEditing = useCallback( const startEditing = useCallback(
async (row, column) => { (row, column) => {
const columnId = column.id; const columnId = column.id;
const rowId = row.id;
if (!isConnectedRef) { if (!isConnectedRef.current) {
onErrorRef.current?.("WebSocket не подключен"); onErrorRef.current?.('WebSocket не подключен');
return false; return false;
} }
@ -201,7 +249,7 @@ export const RealtimeProvider = ({
const colId = columnId.slice(5); // убираем префикс "data." const colId = columnId.slice(5); // убираем префикс "data."
const message = { const message = {
event: "cell_edit_start", event: 'cell_edit_start',
data: { data: {
line_id: lineId, line_id: lineId,
column: colId, column: colId,
@ -215,17 +263,16 @@ export const RealtimeProvider = ({
return true; return true;
}, },
[isConnected, sendMessage], [isConnectedRef, sendMessage],
); );
// Функция для завершения редактирования ячейки // Функция для завершения редактирования ячейки
const endEditing = useCallback( const endEditing = useCallback(
async (row, column) => { (row, column) => {
const columnId = column.id; const columnId = column.id;
const rowId = row.id;
if (!isConnectedRef) { if (!isConnectedRef.current) {
onErrorRef.current?.("WebSocket не подключен"); onErrorRef.current?.('WebSocket не подключен');
return false; return false;
} }
@ -233,7 +280,7 @@ export const RealtimeProvider = ({
const colId = columnId.slice(5); const colId = columnId.slice(5);
const message = { const message = {
event: "cell_edit_end", event: 'cell_edit_end',
data: { data: {
line_id: lineId, line_id: lineId,
column: colId, column: colId,
@ -247,22 +294,21 @@ export const RealtimeProvider = ({
return true; return true;
}, },
[isConnected, sendMessage], [isConnectedRef, sendMessage],
); );
const updateCell = useCallback( const updateCell = useCallback(
async (row, column, value) => { (row, column, value) => {
const columnId = column.id; const columnId = column.id;
const rowId = row.id; if (!isConnectedRef.current) {
if (!isConnectedRef) { onErrorRef.current?.('WebSocket не подключен');
onErrorRef.current?.("WebSocket не подключен");
return false; return false;
} }
const lineId = getRowId(row); const lineId = getRowId(row);
const colId = columnId.slice(5); const colId = columnId.slice(5);
const message = { const message = {
event: "cell_updated", event: 'cell_updated',
data: { data: {
line_id: lineId, line_id: lineId,
line_id_code: row.id, line_id_code: row.id,
@ -278,79 +324,79 @@ export const RealtimeProvider = ({
return true; return true;
}, },
[isConnected, sendMessage], [isConnectedRef, sendMessage],
); );
const addRow = useCallback( const addRow = useCallback(
async (rowData) => { (rowData) => {
if (!isConnectedRef) { if (!isConnectedRef.current) {
onErrorRef.current?.("Нет подключения или авторизации"); onErrorRef.current?.('Нет подключения или авторизации');
return false; return false;
} }
const message = { const message = {
event: "row_added", event: 'row_added',
data: rowData, data: rowData,
}; };
addCommonField(message); addCommonField(message);
return sendMessage(message); return sendMessage(message);
}, },
[isConnected, sendMessage, sheetName, formId, direction], [addCommonField, isConnectedRef, sendMessage],
); );
const addProject = useCallback( const addProject = useCallback(
async (rowData) => { (rowData) => {
if (!isConnectedRef) { if (!isConnectedRef.current) {
onErrorRef.current?.("Нет подключения или авторизации"); onErrorRef.current?.('Нет подключения или авторизации');
return false; return false;
} }
const message = { const message = {
event: "project_added", event: 'project_added',
data: rowData, data: rowData,
}; };
addCommonField(message); addCommonField(message);
return sendMessage(message); return sendMessage(message);
}, },
[isConnected, sendMessage, sheetName, formId, direction], [addCommonField, isConnectedRef, sendMessage],
); );
const addProgram = useCallback( const addProgram = useCallback(
async (rowData) => { (rowData) => {
if (!isConnectedRef) { if (!isConnectedRef.current) {
onErrorRef.current?.("Нет подключения или авторизации"); onErrorRef.current?.('Нет подключения или авторизации');
return false; return false;
} }
const message = { const message = {
event: "program_added", event: 'program_added',
data: rowData, data: rowData,
}; };
addCommonField(message); addCommonField(message);
return sendMessage(message); return sendMessage(message);
}, },
[isConnected, sendMessage, sheetName, formId, direction], [addCommonField, isConnectedRef, sendMessage],
); );
const deleteRow = useCallback( const deleteRow = useCallback(
async (rowId) => { (rowId) => {
if (!isConnectedRef) { if (!isConnectedRef.current) {
onErrorRef.current?.("Нет подключения или авторизации"); onErrorRef.current?.('Нет подключения или авторизации');
return false; return false;
} }
const message = { const message = {
event: "row_deleted", event: 'row_deleted',
data: { row_id: rowId }, data: { row_id: rowId },
}; };
addCommonField(message); addCommonField(message);
return sendMessage(message); return sendMessage(message);
}, },
[isConnected, sendMessage, sheetName, formId, direction], [addCommonField, isConnectedRef, sendMessage],
); );
const subscribeToCellUpdates = useCallback((callback) => { const subscribeToCellUpdates = useCallback((callback) => {
@ -417,42 +463,65 @@ export const RealtimeProvider = ({
}, []); }, []);
const retryLogin = useCallback(() => { const retryLogin = useCallback(() => {
if (isConnected) { if (isConnectedRef.current) {
authAttemptedRef.current = false; authAttemptedRef.current = false;
sendLogin(); 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 ( return (
<RealtimeContext.Provider <RealtimeActionsContext.Provider value={actions}>
value={{ <RealtimeConnectionContext.Provider value={connection}>
isConnected, <RealtimeVspOptionsContext.Provider value={vspOptions}>
error: lastError, <LockedCellsStoreContext.Provider value={lockedCellsStore}>{children}</LockedCellsStoreContext.Provider>
startEditing, </RealtimeVspOptionsContext.Provider>
endEditing, </RealtimeConnectionContext.Provider>
updateCell, </RealtimeActionsContext.Provider>
addRow,
addProgram,
addProject,
deleteRow,
subscribeToRowAdds,
subscribeToRowDeletes,
subscribeToProgramAdds,
subscribeToProjectAdds,
subscribeToErrors,
subscribeToAuthSuccess,
subscribeToCellEditStart,
subscribeToCellEditEnd,
subscribeToCellUpdates,
retryLogin,
lockedCells,
setLockedCells,
vspOptions,
setVspOptions,
releaseAllLocks: () => releaseAllLocks(userId),
}}
>
{children}
</RealtimeContext.Provider>
); );
}; };

View File

@ -33,7 +33,10 @@ export const useColumnSettings = (tableKey) => {
}, [tableKey]); }, [tableKey]);
const handlePinColumn = useCallback((colId) => { 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) => { const handleUnpinColumn = useCallback((colId) => {
@ -63,7 +66,18 @@ export const useColumnSettings = (tableKey) => {
return acc; 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) => {
const entries = Object.entries(visibilityByColumnId);
const hasChanges = entries.some(([columnId, isVisible]) => prev[columnId] !== isVisible);
return hasChanges ? { ...prev, ...visibilityByColumnId } : prev;
});
}, []); }, []);
// Опционально: функция для сброса всех настроек // Опционально: функция для сброса всех настроек
@ -84,6 +98,7 @@ export const useColumnSettings = (tableKey) => {
handleUnpinColumn, handleUnpinColumn,
handleSetColumnWidth, handleSetColumnWidth,
handleToggleColumnVisibility, handleToggleColumnVisibility,
handleSetColumnsVisibility,
resetAllSettings, // добавляем функцию сброса resetAllSettings, // добавляем функцию сброса
tableKey, // опционально возвращаем tableKey tableKey, // опционально возвращаем tableKey
}; };

View File

@ -1,29 +1,54 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useEffect, useState } from 'react';
import { toast } from "react-toastify"; import { toast } from 'react-toastify';
import { FormsSheetApi } from "../../../api/form_sheet"; import { FormsSheetApi } from '../../../api/form_sheet';
import { ProjectsApi } from "../../../api/projects"; import { ProjectsApi } from '../../../api/projects';
import { useRealtime } from "../contexts/RealtimeContext"; import { useRealtimeActions, useRealtimeConnection } from '../contexts/RealtimeContext';
import { import { addRootRow, deleteRow, insertCell, insertProjectRow, updateCells } from '../utils/cellUtils';
addRootRow, import { getParentId, isVspNewRow } from '../utils/rowUtils';
deleteRow,
insertCell, const ROW_TYPE_LEVELS = {
insertProjectRow, ROOT: 0,
updateCells, GROUP: 1,
} from "../utils/cellUtils"; ITEM: 2,
import { getParentId, isVspNewRow } from "../utils/rowUtils"; 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 useRealtimeData = (formId, sheetName, direction, formType, year) => {
const [data, setData] = useState([]); const [data, setData] = useState([]);
const [columnsCurStage, setColumnsCurStage] = useState([]); const [columnsCurStage, setColumnsCurStage] = useState([]);
const [isConnected, setIsConnected] = useState(false);
const [error, setError] = useState(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [editingCells, setEditingCells] = useState({}); const [editingCells, setEditingCells] = useState({});
const dataRef = useRef(data);
const { const {
isConnected: wsConnected,
error: wsError,
subscribeToCellUpdates, subscribeToCellUpdates,
subscribeToRowAdds, subscribeToRowAdds,
subscribeToProgramAdds, subscribeToProgramAdds,
@ -33,62 +58,19 @@ const useRealtimeData = (formId, sheetName, direction, formType, year) => {
subscribeToCellEditStart, subscribeToCellEditStart,
subscribeToCellEditEnd, subscribeToCellEditEnd,
setLockedCells, setLockedCells,
} = useRealtime(); } = useRealtimeActions();
const { isConnected: wsConnected } = useRealtimeConnection();
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;
}, []);
// Загрузка начальных данных // Загрузка начальных данных
useEffect(() => { useEffect(() => {
let isCancelled = false;
const loadInitialData = async () => { const loadInitialData = async () => {
try { try {
setIsLoading(true); setIsLoading(true);
let res; let res;
if (formType === "PROJECT") { if (formType === 'PROJECT') {
res = await ProjectsApi.getTableData(formId, sheetName, year); res = await ProjectsApi.getTableData(formId, sheetName, year);
} else if (direction !== null) { } else if (direction !== null) {
res = await FormsSheetApi.get(formId, sheetName, { res = await FormsSheetApi.get(formId, sheetName, {
@ -97,15 +79,12 @@ const useRealtimeData = (formId, sheetName, direction, formType, year) => {
} else { } else {
res = await FormsSheetApi.get(formId, sheetName); res = await FormsSheetApi.get(formId, sheetName);
} }
if (isCancelled) return;
const data = res.result.filter((d) => d.depth > -1); const data = res.result.filter((d) => d.depth > -1);
const curStageColumns = res.result.filter((d) => d.depth === -1)[0]; const curStageColumns = res.result.filter((d) => d.depth === -1)[0];
if (curStageColumns) { const stageColumns = (curStageColumns?.data?.editable || []).map((column) => `data.${column.column}`);
const cols = []; setColumnsCurStage(stageColumns);
for (const col of curStageColumns.data.editable) {
cols.push(`data.${col.column}`);
}
setColumnsCurStage(cols);
}
if (!data || data.length === 0) { if (!data || data.length === 0) {
setData([]); setData([]);
@ -115,74 +94,18 @@ const useRealtimeData = (formId, sheetName, direction, formType, year) => {
const hierarchicalData = buildHierarchy(data); const hierarchicalData = buildHierarchy(data);
setData(hierarchicalData); setData(hierarchicalData);
} catch (err) { } catch (err) {
console.error("Failed to load initial data:", err); if (!isCancelled) console.error('Failed to load initial data:', err);
setError(err.message);
} finally { } finally {
setIsLoading(false); if (!isCancelled) setIsLoading(false);
} }
}; };
loadInitialData(); loadInitialData();
}, [formId, sheetName]);
const buildHierarchy = (items) => { return () => {
const result = []; isCancelled = true;
const stack = [];
const getTypeLevel = (type) => {
const levels = {
ROOT: 0,
GROUP: 1,
ITEM: 2,
SUB_ITEM: 3,
INPUT: 4,
};
return levels[type] ?? 999;
}; };
}, [direction, formId, formType, sheetName, year]);
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]);
// Подписка на обновления WebSocket // Подписка на обновления WebSocket
useEffect(() => { useEffect(() => {
@ -213,12 +136,12 @@ const useRealtimeData = (formId, sheetName, direction, formType, year) => {
[[], null], [[], null],
); );
if (newRow === null) { if (newRow === null) {
toast.error("Ошибка получения данных"); toast.error('Ошибка получения данных');
return; return;
} }
setData((prevData) => { setData((prevData) => {
let data = structuredClone(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]; const newRowRoot = updates[0];
data = addRootRow(data, newRowRoot); data = addRootRow(data, newRowRoot);
} }
@ -233,7 +156,7 @@ const useRealtimeData = (formId, sheetName, direction, formType, year) => {
const program_id = data.data.program_id; const program_id = data.data.program_id;
const [updatedCells, newRow] = updates.reduce( const [updatedCells, newRow] = updates.reduce(
(res, row) => { (res, row) => {
if (row[0] === "ITEM") { if (row[0] === 'ITEM') {
res[1] = row; res[1] = row;
} else { } else {
res[0].push(row); res[0].push(row);
@ -243,7 +166,7 @@ const useRealtimeData = (formId, sheetName, direction, formType, year) => {
[[], null], [[], null],
); );
if (newRow === null) { if (newRow === null) {
toast.error("Ошибка получения данных"); toast.error('Ошибка получения данных');
return; return;
} }
setData((prevData) => { setData((prevData) => {
@ -256,7 +179,7 @@ const useRealtimeData = (formId, sheetName, direction, formType, year) => {
const unsubscribeProgramAdds = subscribeToProgramAdds((msg) => { const unsubscribeProgramAdds = subscribeToProgramAdds((msg) => {
let newRow = msg.result[1][0]; let newRow = msg.result[1][0];
setData((prevData) => { setData((prevData) => {
const hasRoot = prevData.length > 0 && prevData[0].row_type === "ROOT"; const hasRoot = prevData.length > 0 && prevData[0].row_type === 'ROOT';
if (hasRoot) { if (hasRoot) {
newRow = msg.result[1][1]; newRow = msg.result[1][1];
} }
@ -291,7 +214,7 @@ const useRealtimeData = (formId, sheetName, direction, formType, year) => {
// Обработчик начала редактирования ячейки // Обработчик начала редактирования ячейки
const unsubscribeCellEditStart = subscribeToCellEditStart((data) => { const unsubscribeCellEditStart = subscribeToCellEditStart((data) => {
const dataCell = data.data; const dataCell = data.data;
dataCell.column = "data." + dataCell.column; dataCell.column = `data.${dataCell.column}`;
if (data.is_self) { if (data.is_self) {
setEditingCells(dataCell); setEditingCells(dataCell);
return; return;
@ -318,9 +241,7 @@ const useRealtimeData = (formId, sheetName, direction, formType, year) => {
}); });
const unsubscribeErrors = subscribeToErrors((err) => { const unsubscribeErrors = subscribeToErrors((err) => {
console.error("WebSocket error:", err); console.error('WebSocket error:', err);
setError(err);
setTimeout(() => setError(null), 5000);
}); });
return () => { return () => {
@ -343,35 +264,12 @@ const useRealtimeData = (formId, sheetName, direction, formType, year) => {
subscribeToCellEditEnd, subscribeToCellEditEnd,
subscribeToProgramAdds, subscribeToProgramAdds,
subscribeToProjectAdds, 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 { return {
data, data,
setData,
columnsCurStage, columnsCurStage,
isConnected,
isLoading, isLoading,
error,
editingCells, editingCells,
}; };
}; };

View File

@ -1,32 +1,49 @@
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo, useRef } from 'react';
import { validationRules } from '../constants/validation'; import { validationRules } from '../constants/validation';
import { buildColumnHeaderPaths, collectInvalidErrors, isValidationRuleEnabled } from '../utils/validationUtils'; import { buildColumnHeaderPaths, collectInvalidErrors, isValidationRuleEnabled } from '../utils/validationUtils';
export const useValidationRules = (data = [], columns = [], formType, sheetName) => { const EMPTY_ARRAY = [];
const headerPaths = useMemo(() => buildColumnHeaderPaths(columns), [columns]);
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( const activeRules = useMemo(
() => validationRules.filter((rule) => isValidationRuleEnabled(rule, { formType, sheetName })), () => validationRules.filter((rule) => isValidationRuleEnabled(rule, { formType, sheetName })),
[formType, sheetName], [formType, sheetName],
); );
const errors = useMemo(() => { const calculatedErrors = useMemo(() => {
const result = []; const result = [];
const seen = new Set(); const seen = new Set();
for (const rule of activeRules) { for (const rule of activeRules) {
collectInvalidErrors(data, rule, headerPaths, result, seen); collectInvalidErrors(safeData, rule, headerPaths, result, seen);
} }
return result; 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( const isCellInvalid = useCallback(
(columnId, value, row) => (columnId, value, row) =>
activeRules.some( activeRules.some(
(rule) => (rule) =>
rule.columnKeys.includes(columnId) && rule.columnKeys.includes(columnId) && (!rule.appliesToRow || rule.appliesToRow(row)) && rule.isInvalid(value, row, columnId),
(!rule.appliesToRow || rule.appliesToRow(row)) &&
rule.isInvalid(value, row, columnId),
), ),
[activeRules], [activeRules],
); );

View File

@ -1,32 +1,19 @@
import React, { useEffect, useRef, useState, useMemo, useCallback } from 'react'; import React, { useEffect, useRef, useState, useMemo, useCallback } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { useParams } from 'react-router'; 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 { useAuth } from '../../app/context/AuthProvider';
import { ROLES_NAME_ID } from '../../constants/constants'; import { ROLES_NAME_ID } from '../../constants/constants';
import { blueColumn } from './constants/columnColors';
import { sectionCodeColor } from './constants/columnConfig';
import VspDropdownEditCell from './Cell/EditCell/VspDropdownEditCell'; import VspDropdownEditCell from './Cell/EditCell/VspDropdownEditCell';
const EditCellPortal = React.memo(({ const EditCellPortal = React.memo(({ cell, table, EditCell, onError, isEditable }) => {
cell,
table,
EditCell,
onSaveStart,
onSaveEnd,
onError,
isEditable
}) => {
const { formId, formType, sheetName, direction } = useParams(); const { formId, formType, sheetName, direction } = useParams();
const tableKey = `${formId}_${formType}_${sheetName}_${direction}`; const tableKey = `${formId}_${formType}_${sheetName}_${direction}`;
const ref = useRef(null); const ref = useRef(null);
const [refTbody, setRefTbody] = useState(null); const [refTbody, setRefTbody] = useState(null);
const [isSaving, setIsSaving] = useState(false);
const { endEditing: contextEndEditing } = useRealtime();
useEffect(() => { useEffect(() => {
if (ref.current) { if (ref.current) {
const tbodyRef = ref.current.offsetParent?.offsetParent?.offsetParent; const tbodyRef = ref.current.offsetParent?.offsetParent?.offsetParent;
@ -36,54 +23,50 @@ const EditCellPortal = React.memo(({
} }
}, []); }, []);
const handleChange = useCallback((val) => { const handleChange = useCallback(
table.setEditingCell(null); (val) => {
try { table.setEditingCell(null);
if (table.options.meta?.updateCell) { try {
const success = table.options.meta.updateCell( if (table.options.meta?.updateCell) {
cell.row, const success = table.options.meta.updateCell(cell.row, cell.column, val);
cell.column, if (!success) {
val console.error('Failed to update cell via WebSocket');
); onError?.('Не удалось сохранить изменение');
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); [table, cell, onError],
onError?.(error.message); );
}
}, [table, cell, onError]);
const editCellProps = useMemo(() => ({ const editCellProps = useMemo(
refCell: ref, () => ({
cell, refCell: ref,
value: cell.getValue(), cell,
disabled: !isEditable, value: cell.getValue(),
onChange: handleChange, disabled: !isEditable,
tableId: tableKey, onChange: handleChange,
table, tableId: tableKey,
cellId: `${cell.row.id}_${cell.column.id}`, table,
}), [cell, isEditable, handleChange, tableKey, table]); cellId: `${cell.row.id}_${cell.column.id}`,
}),
[cell, isEditable, handleChange, tableKey, table],
);
const editType = cell.column.columnDef?.editType; const editType = cell.column.columnDef?.editType;
const portalContent = useMemo(() => { const portalContent = useMemo(() => {
if (!refTbody || isSaving) return null; if (!refTbody) return null;
if (editType === 'vsp_dropdown') { if (editType === 'vsp_dropdown') {
return createPortal( return createPortal(<VspDropdownEditCell {...editCellProps} />, refTbody);
<VspDropdownEditCell {...editCellProps} />,
refTbody
);
} }
return createPortal( return createPortal(<EditCell {...editCellProps} />, refTbody);
<EditCell {...editCellProps} />, }, [refTbody, editCellProps, EditCell, editType]);
refTbody
);
}, [refTbody, isSaving, editCellProps, EditCell, editType]);
return ( return (
<> <>
@ -94,18 +77,41 @@ const EditCellPortal = React.memo(({
}); });
function getColorCell(colors, column, row) { function getColorCell(colors, column, row) {
if (row.original.data.section_code) { const rowType = row?.original?.row_type || row?.row_type;
const section_code = row.original.data.section_code[0]; const sectionCodeValue = row?.original?.data?.section_code ?? row?.original?.data?.header?.section_code;
return (sectionCodeColor[section_code] || blueColumn)[row.original?.row_type || row.row_type];
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);
});
};
const CellWithVspOptions = React.memo(({ CellComponent, cellProps }) => {
const vspOptions = useVspOptions();
return <CellComponent {...cellProps} vspOptions={vspOptions} />;
});
export const getTableColumns = ({ export const getTableColumns = ({
Cell, Cell,
EditCell, EditCell,
columnsConfig, columnsConfig,
columnsCurStage, columnsCurStage,
userRoleId,
onCellUpdateError, onCellUpdateError,
onCellNumberClick, onCellNumberClick,
isCellInvalid, isCellInvalid,
@ -113,24 +119,23 @@ export const getTableColumns = ({
const columnColors = { ...columnsConfig.colors }; const columnColors = { ...columnsConfig.colors };
const columns = structuredClone(columnsConfig.columns); const columns = structuredClone(columnsConfig.columns);
const { user } = useAuth();
const cellPropsCache = new WeakMap(); const cellPropsCache = new WeakMap();
const editPropsCache = new WeakMap(); const editPropsCache = new WeakMap();
const isEditable = (row, column) => { const isEditable = (row, column) => {
if (row.original?.row_type !== 'INPUT') return false; 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; const keyCol = column.id;
if (columnsCurStage.includes(keyCol)) return true; if (columnsCurStage.includes(keyCol)) return true;
return false; return false;
} };
const getCachedCellProps = (row, column, table) => { const getCachedCellProps = (row, column, table) => {
const key = `${row.id}_${column.id}`; const key = `${row.id}_${column.id}`;
const value = row.getValue(column.id); const value = row.getValue(column.id);
const isInvalid = isCellInvalid?.(column.id, value, row.original) || false; const isInvalid = isCellInvalid?.(column.id, value, row.original) || false;
const isEditableCell = isEditable(row, column);
const backgroundColor = getColorCell(columnColors, column, row);
if (!cellPropsCache.has(row)) { if (!cellPropsCache.has(row)) {
cellPropsCache.set(row, {}); cellPropsCache.set(row, {});
} }
@ -139,35 +144,37 @@ export const getTableColumns = ({
if (rowCache[key]) { if (rowCache[key]) {
const cached = rowCache[key]; const cached = rowCache[key];
const currentGlobalFilter = table.getState().globalFilter; 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 && if (
cached.globalFilter === currentGlobalFilter &&
cached.columnFilter === currentColumnFilter && cached.columnFilter === currentColumnFilter &&
cached.value === value && cached.value === value &&
cached.isInvalid === isInvalid) { cached.isInvalid === isInvalid &&
cached.backgroundColor === backgroundColor
) {
return cached; return cached;
} }
} }
const globalFilter = table.getState().globalFilter; 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 = { const props = {
globalFilter, globalFilter,
columnFilter, columnFilter,
isEditable: isEditable(row, column), isEditable: isEditableCell,
backgroundColor: getColorCell(columnColors, column, row), backgroundColor,
isUpdating: table.options.meta?.updatingCells?.[`${row.id}_${column.id}`],
isInvalid, isInvalid,
value, value,
_hash: `${globalFilter}_${columnFilter}_${row.id}_${column.id}_${value}_${isInvalid}`, _hash: `${globalFilter}_${columnFilter}_${key}_${value}_${isInvalid}_${backgroundColor}`,
}; };
rowCache[key] = props; rowCache[key] = props;
return props; return props;
}; };
const getCachedEditProps = (row, column, table) => { const getCachedEditProps = (row, column) => {
const key = `${row.id}_${column.id}`; const key = `${row.id}_${column.id}`;
if (!editPropsCache.has(row)) { if (!editPropsCache.has(row)) {
@ -190,33 +197,32 @@ export const getTableColumns = ({
// Оптимизированная функция processColumns // Оптимизированная функция processColumns
const processColumns = (columns) => { const processColumns = (columns) => {
for (const col of columns) { for (const col of columns) {
col.header = normalizeMonthHeader(col.header);
const usesVspOptions = col.editType === 'vsp_dropdown';
col.Cell = ({ cell, table, column, row }) => { col.Cell = ({ cell, table, column, row }) => {
const { vspOptions } = useRealtime();
const props = getCachedCellProps(row, column, table); const props = getCachedCellProps(row, column, table);
const cellProps = useMemo(() => ({ const cellProps = useMemo(
row, () => ({
column, row,
cell, column,
vspOptions, cell,
...props, ...props,
}), [row, column, cell, props, vspOptions,]); }),
[row, column, cell, props],
);
return <Cell {...cellProps} />; return usesVspOptions ? <CellWithVspOptions CellComponent={Cell} cellProps={cellProps} /> : <Cell {...cellProps} />;
}; };
col.Edit = ({ cell, table, row, column }) => { col.Edit = ({ cell, table, row, column }) => {
const props = getCachedEditProps(row, column, table); const props = getCachedEditProps(row, column);
const editComponent = useMemo(() => ( const editComponent = useMemo(
<EditCellPortal () => <EditCellPortal cell={cell} table={table} EditCell={EditCell} onError={onCellUpdateError} isEditable={props.isEditable} />,
cell={cell} [cell, table, EditCell, onCellUpdateError, props.isEditable],
table={table} );
EditCell={EditCell}
onError={onCellUpdateError}
isEditable={props.isEditable}
/>
), [cell, table, EditCell, onCellUpdateError, props.isEditable]);
return editComponent; return editComponent;
}; };
@ -229,23 +235,22 @@ export const getTableColumns = ({
processColumns(col.columns); processColumns(col.columns);
} }
} }
} };
processColumns(columns); processColumns(columns);
const rowNumber = { const rowNumber = {
accessorKey: 'sort_order', accessorKey: 'sort_order',
header: '#', header: '',
size: 50, size: 50,
enableEditing: false, enableEditing: false,
Cell: ({ cell, row }) => { Cell: ({ cell, row }) => {
const handleClick = useCallback(() => { const handleClick = useCallback(() => {
onCellNumberClick(row.id); onCellNumberClick(row.id);
}, [row.id, onCellNumberClick]); }, [row.id, onCellNumberClick]);
return <button type="button" onClick={handleClick}>{`${cell.getValue()} `}</button>; return <button type='button' onClick={handleClick}>{`${cell.getValue()} `}</button>;
}, },
}; };
return [rowNumber, ...columns]; return [rowNumber, ...columns];
}; };

View File

@ -2,35 +2,38 @@ import { ArrowDropDown, ChevronRight, ExpandMore } from '@mui/icons-material';
import { CheckBox, CheckBoxOutlineBlank, IndeterminateCheckBox } from '@mui/icons-material'; import { CheckBox, CheckBoxOutlineBlank, IndeterminateCheckBox } from '@mui/icons-material';
import { Box, Checkbox, InputAdornment, Popover, TextField, Typography } from '@mui/material'; import { Box, Checkbox, InputAdornment, Popover, TextField, Typography } from '@mui/material';
import { SimpleTreeView, TreeItem } from '@mui/x-tree-view'; 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'; import { filterColumnTree, getColumnTreeLeafKeys } from '../RealtimeTable/utils/columnUtils';
const EMPTY_IDS = [];
const CollapsibleTree = ({ const CollapsibleTree = ({
columnTree, columnTree,
excludeColumnKeys = [], excludeColumnKeys = EMPTY_IDS,
placeholder = 'Скрыть столбцы', placeholder = 'Скрыть столбцы',
placeholderSelect = 'Скрыто столбцов: ', placeholderSelect = 'Скрыто столбцов: ',
selectedIds = [], selectedIds = EMPTY_IDS,
onSelectNode, onSelectNode,
onSelectAllNodes,
forStage = false, forStage = false,
}) => { }) => {
const [expandedItems, setExpandedItems] = useState([]); const [expandedItems, setExpandedItems] = useState([]);
const [anchorEl, setAnchorEl] = useState(null); const [anchorEl, setAnchorEl] = useState(null);
const [selectedNodes, setSelectedNodes] = useState([]); const selectedNodes = selectedIds;
useEffect(() => {
setSelectedNodes(selectedIds);
}, [selectedIds]);
const visibleColumnTree = useMemo(() => filterColumnTree(columnTree, excludeColumnKeys), [columnTree, excludeColumnKeys]); const visibleColumnTree = useMemo(() => filterColumnTree(columnTree, excludeColumnKeys), [columnTree, excludeColumnKeys]);
const visibleLeafKeys = useMemo(() => getColumnTreeLeafKeys(visibleColumnTree), [visibleColumnTree]); const visibleLeafKeys = useMemo(() => getColumnTreeLeafKeys(visibleColumnTree), [visibleColumnTree]);
const handleExpandedItemsChange = (event, itemIds) => { const handleExpandedItemsChange = (_event, itemIds) => {
setExpandedItems(itemIds); setExpandedItems(itemIds);
}; };
const handleSelectAll = (isSelected) => { const handleSelectAll = (isSelected) => {
if (onSelectAllNodes) {
onSelectAllNodes(visibleLeafKeys, !isSelected);
return;
}
for (const col of visibleColumnTree) { for (const col of visibleColumnTree) {
onSelectNode?.(col, !isSelected); onSelectNode?.(col, !isSelected);
} }
@ -248,4 +251,4 @@ const CollapsibleTree = ({
); );
}; };
export default CollapsibleTree; export default memo(CollapsibleTree);

View File

@ -1,5 +1,5 @@
import { InputAdornment, TextField } from '@mui/material'; import { InputAdornment, TextField } from '@mui/material';
import { useEffect, useRef, useState } from 'react'; import { memo, useEffect, useRef, useState } from 'react';
import { Search } from './icons/icons'; import { Search } from './icons/icons';
function SearchComponent({ placeholder = 'Поиск...', value = '', onChange, debounceMs = 0, height = '2rem', sx: sxProp, ...rest }) { 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);

View File

@ -281,6 +281,9 @@ const UserTableForAdd = ({ usersEmail = [], sspOptions = [], onDeleteUser, onCha
maxHeight: '18.38rem', maxHeight: '18.38rem',
}, },
}, },
localization: {
noRecordsToDisplay: 'Нет данных для отображения',
},
enableRowNumbers: false, enableRowNumbers: false,
muiToolbarAlertBannerProps: false, muiToolbarAlertBannerProps: false,
}); });

View File

@ -472,6 +472,13 @@ const SummaryPage = () => {
columns: firstColumns, columns: firstColumns,
data: filteredData, data: filteredData,
...projectsTableOptions, ...projectsTableOptions,
localization: {
expand: 'Раскрыть',
expandAll: 'Раскрыть все',
collapse: 'Свернуть',
collapseAll: 'Свернуть все',
noRecordsToDisplay: 'Нет данных для отображения',
},
initialState: { initialState: {
columnPinning: { right: ['actions'], left: ['mrt-row-expand', 'project'] }, columnPinning: { right: ['actions'], left: ['mrt-row-expand', 'project'] },
}, },
@ -482,6 +489,9 @@ const SummaryPage = () => {
columns: secondColumns, columns: secondColumns,
data: summaryData, data: summaryData,
...summaryProjectTableOptions, ...summaryProjectTableOptions,
localization: {
noRecordsToDisplay: 'Нет данных для отображения',
},
initialState: { initialState: {
columnPinning: { left: ['data.header.name'] }, columnPinning: { left: ['data.header.name'] },
}, },