This commit is contained in:
PotapovaA 2026-05-26 19:06:01 +03:00
parent bc05bc8154
commit 93881c7194
10 changed files with 983 additions and 603 deletions

View File

@ -1,14 +1,16 @@
import api from './client';
import api from "./client";
export const UsersApi = {
me() {
return api.get('/users/me').then((r) => r.data);
return api.get("/users/me").then((r) => r.data);
},
getById(userId) {
return api.get(`/users/${userId}`).then((r) => r.data);
},
getAll() {
return api.get('/users/').then((r) => r.data);
return api
.get("/users/", { params: { limit: 1000, skip: 0 } })
.then((r) => r.data);
},
update(idUser, data) {
return api.patch(`/users/${idUser}`, data).then((r) => r.data);

View File

@ -1,79 +1,210 @@
import React from 'react';
import { useMemo } from 'react';
import styled from '@emotion/styled';
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { Box, Tooltip, CircularProgress } from '@mui/material';
import { useRealtime } from '../../contexts/RealtimeContext';
const ContainerForText = styled.span`
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
overflow: hidden;
white-space: pre-wrap;
`;
const Cell = ({ value, row, column, onSave, userId, isEditable = true }) => {
const [isEditing, setIsEditing] = useState(false);
const [editValue, setEditValue] = useState(value);
const [isSaving, setIsSaving] = useState(false);
const [lockInfo, setLockInfo] = useState(null);
// Функция для подсветки текста
const highlightText = (text, searchQuery) => {
if (!searchQuery || !text) return text;
const {
isCellLocked,
getCellLockInfo,
updateCell,
subscribeToErrors,
tryLockCell,
unlockCell
} = useRealtime();
// Экранируем спецсимволы в поисковом запросе
const escapedQuery = searchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(`(${escapedQuery})`, 'gi');
const inputRef = useRef(null);
const rowId = row.original?.id || row.id || row.index;
const columnId = column.id;
const parts = text.split(regex);
// Проверяем блокировку ячейки
useEffect(() => {
if (!isEditing) {
const checkLock = () => {
const lock = getCellLockInfo(rowId, columnId);
setLockInfo(lock);
};
return parts.map((part, index) =>
regex.test(part) ? (
<mark
key={index}
style={{
backgroundColor: '#ffeb3b',
color: '#000',
fontWeight: 'bold',
padding: '0 2px',
borderRadius: '2px',
}}
checkLock();
const interval = setInterval(checkLock, 2000); // Проверяем каждые 2 секунды
return () => clearInterval(interval);
}
}, [rowId, columnId, isEditing, getCellLockInfo]);
// Подписка на ошибки
useEffect(() => {
const unsubscribe = subscribeToErrors((error) => {
console.error('Cell update error:', error);
setIsSaving(false);
setIsEditing(false);
});
return unsubscribe;
}, [subscribeToErrors]);
const isLocked = isCellLocked(rowId, columnId);
const isLockedByMe = lockInfo?.userId === userId;
const handleDoubleClick = useCallback(() => {
if (!isEditable) return;
if (!isLocked || isLockedByMe) {
// Пытаемся заблокировать ячейку
if (tryLockCell(rowId, columnId, userId)) {
setIsEditing(true);
setEditValue(value);
setTimeout(() => inputRef.current?.focus(), 100);
} else {
const lock = getCellLockInfo(rowId, columnId);
console.warn(`Cell is locked by user ${lock?.userId}`);
}
}
}, [isEditable, isLocked, isLockedByMe, tryLockCell, rowId, columnId, userId, value, getCellLockInfo]);
const handleSave = useCallback(async () => {
if (editValue === value) {
setIsEditing(false);
unlockCell(rowId, columnId, userId);
return;
}
setIsSaving(true);
const success = await updateCell(rowId, columnId, editValue);
if (success) {
onSave?.(row, column, editValue);
setIsEditing(false);
} else {
setIsSaving(false);
}
}, [editValue, value, updateCell, rowId, columnId, userId, onSave, row, column, unlockCell]);
const handleKeyDown = useCallback((e) => {
if (e.key === 'Enter') {
handleSave();
} else if (e.key === 'Escape') {
setIsEditing(false);
setEditValue(value);
unlockCell(rowId, columnId, userId);
}
}, [handleSave, value, rowId, columnId, userId, unlockCell]);
// Отмена блокировки при размонтировании, если ячейка редактировалась
useEffect(() => {
return () => {
if (isEditing) {
unlockCell(rowId, columnId, userId);
}
};
}, [isEditing, rowId, columnId, userId, unlockCell]);
const getLockTooltip = () => {
if (!isEditable) return 'This cell is not editable';
if (isLocked && !isLockedByMe) {
return `Editing by user ${lockInfo?.userId}`;
}
if (isLockedByMe && isEditing) {
return 'You are editing this cell';
}
return 'Double-click to edit';
};
const getCellStyle = () => {
const baseStyle = {
padding: '8px',
minHeight: '40px',
position: 'relative',
};
if (!isEditable) {
return {
...baseStyle,
backgroundColor: '#fafafa',
color: '#999',
cursor: 'not-allowed',
};
}
if (isLocked && !isLockedByMe) {
return {
...baseStyle,
backgroundColor: '#f5f5f5',
opacity: 0.7,
cursor: 'not-allowed',
};
}
return {
...baseStyle,
cursor: 'text',
'&:hover': {
backgroundColor: '#f0f0f0',
},
};
};
if (isEditing) {
return (
<Box sx={{ position: 'relative', width: '100%' }}>
<input
ref={inputRef}
type="text"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={handleSave}
onKeyDown={handleKeyDown}
disabled={isSaving}
style={{
width: '100%',
padding: '8px',
border: '2px solid #1976d2',
borderRadius: '4px',
fontSize: 'inherit',
outline: 'none',
}}
/>
{isSaving && (
<CircularProgress
size={20}
sx={{
position: 'absolute',
right: 8,
top: '50%',
transform: 'translateY(-50%)',
}}
/>
)}
</Box>
);
}
return (
<Tooltip title={getLockTooltip()} placement="top" arrow>
<Box
onDoubleClick={handleDoubleClick}
sx={getCellStyle()}
>
{part}
</mark>
) : (
part
),
{value || ''}
{isLockedByMe && !isEditing && (
<Box
sx={{
position: 'absolute',
top: 2,
right: 2,
width: 8,
height: 8,
borderRadius: '50%',
backgroundColor: '#ff9800',
}}
/>
)}
</Box>
</Tooltip>
);
};
const Cell = React.memo(({ cell, onClick, globalFilter, backgroundColor, color }) => {
const cellValue = cell.getValue();
const textValue = String(cellValue || '');
// Подсвечиваем текст с учетом поискового запроса
const highlightedContent = useMemo(() => {
return highlightText(textValue, globalFilter);
}, [textValue, globalFilter]);
return (
<div
className="cell"
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxSizing: 'border-box',
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
border: '2px solid transparent',
padding: '8px',
backgroundColor: backgroundColor,
color: color,
}}
onClick={onClick}
>
<ContainerForText>{highlightedContent}</ContainerForText>
</div>
);
});
export default Cell;

View File

@ -4,8 +4,9 @@ import {
useMaterialReactTable,
MaterialReactTable,
} from 'material-react-table';
import { CircularProgress, Typography, Box } from '@mui/material';
import useRealtimeData from './useRealtimeData';
import useRealtimeData from './hooks/useRealtimeData';
import { getTableColumns } from './tableColumns';
import { Cell, EditCell } from './index';
import { TableHead } from './TableHead/TableHead';
@ -20,12 +21,29 @@ import {
getTablePaperStyles,
} from './constants/tableConfig';
const RealtimeTable = ({ formType, formId, sheetName }) => {
const { data, setData } = useRealtimeData(formId, sheetName);
const [isLoading, setIsLoading] = useState(true);
// Импортируем WebSocket контекст
import { RealtimeProvider, useRealtime } from './contexts/RealtimeContext';
// Внутренний компонент, который будет использовать WebSocket
const RealtimeTableContent = ({ formType, formId, sheetName }) => {
// Получаем данные и состояние из хука
const {
data,
setData,
isConnected,
isLoading: isDataLoading,
error: wsError
} = useRealtimeData(formId, sheetName);
// isConnected = true
// Получаем функции из WebSocket контекста
const { login, releaseAllLocks } = useRealtime();
const [globalFilter, setGlobalFilter] = useState('');
const [showColumnFilters, setShowColumnFilters] = useState(false);
const [selectedColumn, setSelectedColumn] = useState();
const [columnsConfig, setColumnsConfig] = useState(null);
const [userId, setUserId] = useState(null);
const columnVirtualizerInstanceRef = useRef(null);
@ -42,18 +60,35 @@ const RealtimeTable = ({ formType, formId, sheetName }) => {
} = useColumnSettings();
const { headerPortalRef, containerRef } = useHeaderPortal();
const { sizeMult, setSizeMult, tableScaleStyle, tableWrapperStyle } = useTableScale(containerRef);
const { sizeMult, setSizeMult, tableScaleStyle, tableWrapperStyle } =
useTableScale(containerRef);
// Получаем ID пользователя
useEffect(() => {
const getUserId = async () => {
try {
const token = localStorage.getItem('access_token');
if (token) {
// Декодируем JWT токен
const payload = JSON.parse(atob(token.split('.')[1]));
setUserId(payload.user_id || payload.sub || payload.id);
} else {
// Для разработки
setUserId('anonymous_' + Date.now());
}
} catch (error) {
console.error('Failed to get user ID:', error);
setUserId('anonymous_' + Date.now());
}
};
const [columnsConfig, setColumnsConfig] = useState(null);
getUserId();
}, []);
// Загрузка конфигурации колонок
useEffect(() => {
const loadConfig = async () => {
try {
const { config } = await import(`./constants/${formType}/${sheetName}.js`);
// Make sure config.configData has the expected structure
setColumnsConfig(config.config);
} catch (error) {
console.error(`Failed to load data for ${formType}/${sheetName}:`, error);
@ -64,30 +99,66 @@ const RealtimeTable = ({ formType, formId, sheetName }) => {
loadConfig();
}, [formType, sheetName]);
// Логинимся в WebSocket
useEffect(() => {
if (isConnected && userId) {
const token = localStorage.getItem('access_token');
if (token) {
login(token);
}
}
}, [isConnected, userId, login]);
// Освобождаем блокировки при размонтировании
useEffect(() => {
return () => {
releaseAllLocks();
};
}, [releaseAllLocks]);
// Обработчик сохранения ячейки (теперь только для оптимистичного обновления UI)
const handleSaveCell = async (row, column, value) => {
// Обновление данных происходит через WebSocket в Cell компоненте
// Здесь просто обновляем UI для оптимистичного отображения
const updatedData = [...data];
const rowIndex = data.findIndex(r => (r.id || r.line_id || r.index) === row.id);
if (rowIndex !== -1) {
updatedData[rowIndex][column.id] = value;
setData(updatedData);
}
};
// Создаем колонки с передачей userId в Cell
const columns = useMemo(() => {
if (!columnsConfig || !columnsConfig.columns) return [];
try {
return getTableColumns({ Cell, EditCell, columnsConfig });
// Оборачиваем Cell компонент для передачи userId
const CellWithProps = (props) => (
<Cell
{...props}
userId={userId}
onSave={handleSaveCell}
/>
);
return getTableColumns({
Cell: CellWithProps,
EditCell,
columnsConfig
});
} catch (error) {
console.error('Error creating columns:', error);
return [];
}
}, [columnsConfig]);
}, [columnsConfig, userId, data]);
// Обработчик сохранения ячейки
const handleSaveCell = async (cell, value) => {
const updatedData = [...data];
updatedData[cell.row.index][cell.column.id] = value;
setData(updatedData);
};
// Конфигурация таблицы
// Конфигурация таблицы (без изменений)
const tableConfig = useMemo(
() => ({
...BASE_TABLE_CONFIG,
columns,
data,
// data: [],
columnVirtualizerInstanceRef,
enableRowVirtualization: false,
onColumnSizingChange: setColumnSizing,
@ -100,9 +171,6 @@ const RealtimeTable = ({ formType, formId, sheetName }) => {
globalFilter,
showColumnFilters,
},
initialState: {
// expanded: true,
},
muiTableHeadCellProps: ({ column }) => ({
sx: {
width: column.getSize(),
@ -161,6 +229,34 @@ const RealtimeTable = ({ formType, formId, sheetName }) => {
const table = useMaterialReactTable(tableConfig);
// Показываем индикатор загрузки
if (isDataLoading) {
return (
<Box display="flex" justifyContent="center" alignItems="center" minHeight="400px">
<CircularProgress />
<Typography sx={{ ml: 2 }}>Загрузка данных...</Typography>
</Box>
);
}
// Показываем ошибку подключения
if (!isConnected) {
return (
<Box display="flex" justifyContent="center" alignItems="center" minHeight="400px">
<CircularProgress />
<Typography sx={{ ml: 2 }}>Подключение к серверу...</Typography>
</Box>
);
}
if (wsError) {
return (
<Box display="flex" justifyContent="center" alignItems="center" minHeight="400px">
<Typography color="error">Ошибка подключения: {wsError}</Typography>
</Box>
);
}
return (
<>
<SettingsPanel
@ -207,4 +303,34 @@ const RealtimeTable = ({ formType, formId, sheetName }) => {
);
};
// Основной компонент с провайдером WebSocket
const RealtimeTable = ({ formType, formId, sheetName }) => {
const [direction, setDirection] = useState(null);
// Получаем direction из URL или пропсов если нужно
useEffect(() => {
// Пример получения direction из URL параметров
const urlParams = new URLSearchParams(window.location.search);
const dir = urlParams.get('direction');
if (dir) {
setDirection(dir);
}
}, []);
return (
<RealtimeProvider
formId={formId}
sheetName={sheetName}
direction={direction}
userId={null}
>
<RealtimeTableContent
formType={formType}
formId={formId}
sheetName={sheetName}
/>
</RealtimeProvider>
);
};
export default React.memo(RealtimeTable);

View File

@ -0,0 +1,176 @@
import React, { createContext, useContext, useCallback, useRef, useState } from 'react';
import { useWebSocket } from '../hooks/useWebSocket';
import { useCellLocks } from '../hooks/useCellLocks';
import { Config } from '../../../conf/config';
const RealtimeContext = createContext(null);
export const useRealtime = () => {
const context = useContext(RealtimeContext);
if (!context) {
throw new Error('useRealtime must be used within RealtimeProvider');
}
return context;
};
export const RealtimeProvider = ({ children, formId, sheetName, direction, userId }) => {
const [pendingUpdates, setPendingUpdates] = useState(new Map());
// Формируем URL в соответствии с вашим бэкендом
const wsUrl = `${process.env.REACT_APP_WS_URL || 'ws://localhost:8000'}/ws/form/${formId}/sheet/${sheetName}${direction ? `?direction=${direction}` : ''}`;
const { isConnected, sendMessage, login, error } = useWebSocket(wsUrl, handleMessage);
const {
isCellLocked,
tryLockCell,
unlockCell,
getCellLockInfo,
releaseAllLocks
} = useCellLocks(userId);
const onCellUpdateRef = useRef(null);
const onRowAddRef = useRef(null);
const onRowDeleteRef = useRef(null);
const onErrorRef = useRef(null);
function handleMessage(data) {
// Обработка ошибок от бэкенда
if (data.error) {
onErrorRef.current?.(data.error);
return;
}
// Ваш бэкенд возвращает result после успешной операции
switch (data.event) {
case 'cell_updated':
if (data.result && onCellUpdateRef.current) {
// Ваш бэкенд возвращает результат в виде списка обновленных ячеек
// Формат: [{ line_id, column, value }]
const updatedCells = Array.isArray(data.result) ? data.result : [data.result];
onCellUpdateRef.current(updatedCells);
}
break;
case 'row_added':
if (data.result && onRowAddRef.current) {
// Ваш бэкенд возвращает добавленную строку
onRowAddRef.current(data.result);
}
break;
case 'row_deleted':
if (data.result && onRowDeleteRef.current) {
// Ваш бэкенд возвращает ID удаленной строки
onRowDeleteRef.current(data.result);
}
break;
case 'user_login':
console.log('User logged in:', data.user_id);
break;
default:
console.log('Unknown event:', data.event);
}
}
const updateCell = useCallback(async (rowId, columnId, value) => {
// Пытаемся заблокировать ячейку
if (!tryLockCell(rowId, columnId, userId)) {
const lockInfo = getCellLockInfo(rowId, columnId);
onErrorRef.current?.(`Ячейка редактируется пользователем ${lockInfo?.userId}`);
return false;
}
// Формируем сообщение в формате вашего бэкенда
const message = {
event: 'cell_updated',
data: {
line_id: rowId,
column: columnId,
value: value
}
};
// Сохраняем pending update для оптимистичного UI
const updateKey = `${rowId}_${columnId}`;
setPendingUpdates(prev => new Map(prev).set(updateKey, { value, timestamp: Date.now() }));
const sent = sendMessage(message);
if (!sent) {
// Откатываем блокировку если сообщение не отправлено
unlockCell(rowId, columnId, userId);
setPendingUpdates(prev => {
const newMap = new Map(prev);
newMap.delete(updateKey);
return newMap;
});
return false;
}
return true;
}, [sendMessage, tryLockCell, unlockCell, getCellLockInfo, userId]);
const addRow = useCallback(async (rowData) => {
const message = {
event: 'row_added',
data: rowData
};
return sendMessage(message);
}, [sendMessage]);
const deleteRow = useCallback(async (rowId) => {
const message = {
event: 'row_deleted',
data: { row_id: rowId }
};
return sendMessage(message);
}, [sendMessage]);
const subscribeToCellUpdates = useCallback((callback) => {
onCellUpdateRef.current = callback;
return () => { onCellUpdateRef.current = null; };
}, []);
const subscribeToRowAdds = useCallback((callback) => {
onRowAddRef.current = callback;
return () => { onRowAddRef.current = null; };
}, []);
const subscribeToRowDeletes = useCallback((callback) => {
onRowDeleteRef.current = callback;
return () => { onRowDeleteRef.current = null; };
}, []);
const subscribeToErrors = useCallback((callback) => {
onErrorRef.current = callback;
return () => { onErrorRef.current = null; };
}, []);
const loginUser = useCallback((token) => {
login(token);
}, [login]);
return (
<RealtimeContext.Provider value={{
isConnected,
error,
updateCell,
addRow,
deleteRow,
isCellLocked: (rowId, columnId) => isCellLocked(rowId, columnId, userId),
getCellLockInfo: (rowId, columnId) => getCellLockInfo(rowId, columnId),
tryLockCell: (rowId, columnId) => tryLockCell(rowId, columnId, userId),
subscribeToCellUpdates,
subscribeToRowAdds,
subscribeToRowDeletes,
subscribeToErrors,
login: loginUser,
releaseAllLocks: () => releaseAllLocks(userId)
}}>
{children}
</RealtimeContext.Provider>
);
};

View File

@ -0,0 +1,122 @@
import { useState, useCallback, useRef, useEffect } from 'react';
export const useCellLocks = (userId, lockTimeout = 30000) => {
const [lockedCells, setLockedCells] = useState(new Map());
const lockTimersRef = useRef(new Map());
const getCellKey = (rowId, columnId) => `${rowId}_${columnId}`;
const lockCell = useCallback((rowId, columnId, lockingUserId) => {
const cellKey = getCellKey(rowId, columnId);
setLockedCells(prev => {
const newLocks = new Map(prev);
const existingLock = newLocks.get(cellKey);
if (existingLock && existingLock.userId !== lockingUserId) {
return prev; // Cell already locked by another user
}
newLocks.set(cellKey, {
userId: lockingUserId,
lockedAt: Date.now(),
rowId,
columnId
});
return newLocks;
});
return true;
}, []);
const unlockCell = useCallback((rowId, columnId, unlockingUserId) => {
const cellKey = getCellKey(rowId, columnId);
setLockedCells(prev => {
const newLocks = new Map(prev);
const lock = newLocks.get(cellKey);
if (lock && lock.userId === unlockingUserId) {
newLocks.delete(cellKey);
}
return newLocks;
});
// Clear timer if exists
if (lockTimersRef.current.has(cellKey)) {
clearTimeout(lockTimersRef.current.get(cellKey));
lockTimersRef.current.delete(cellKey);
}
}, []);
const tryLockCell = useCallback((rowId, columnId, currentUserId) => {
const cellKey = getCellKey(rowId, columnId);
const existingLock = lockedCells.get(cellKey);
if (existingLock && existingLock.userId !== currentUserId) {
return false; // Cell is locked by another user
}
if (!existingLock) {
lockCell(rowId, columnId, currentUserId);
// Auto-unlock after timeout
const timer = setTimeout(() => {
unlockCell(rowId, columnId, currentUserId);
}, lockTimeout);
lockTimersRef.current.set(cellKey, timer);
}
return true;
}, [lockedCells, lockCell, unlockCell, lockTimeout]);
const releaseAllLocks = useCallback((userId) => {
setLockedCells(prev => {
const newLocks = new Map(prev);
for (const [key, lock] of newLocks.entries()) {
if (lock.userId === userId) {
newLocks.delete(key);
if (lockTimersRef.current.has(key)) {
clearTimeout(lockTimersRef.current.get(key));
lockTimersRef.current.delete(key);
}
}
}
return newLocks;
});
}, []);
const isCellLocked = useCallback((rowId, columnId, currentUserId) => {
const cellKey = getCellKey(rowId, columnId);
const lock = lockedCells.get(cellKey);
return lock && lock.userId !== currentUserId;
}, [lockedCells]);
const getCellLockInfo = useCallback((rowId, columnId) => {
const cellKey = getCellKey(rowId, columnId);
return lockedCells.get(cellKey);
}, [lockedCells]);
useEffect(() => {
return () => {
// Clear all timers on unmount
for (const timer of lockTimersRef.current.values()) {
clearTimeout(timer);
}
lockTimersRef.current.clear();
};
}, []);
return {
lockedCells,
lockCell,
unlockCell,
tryLockCell,
isCellLocked,
getCellLockInfo,
releaseAllLocks
};
};

View File

@ -0,0 +1,202 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { FormsSheetApi } from "../../../api/form_sheet";
import { useRealtime } from "../contexts/RealtimeContext";
const useRealtimeData = (formId, sheetName) => {
const [data, setData] = useState([]);
const [isConnected, setIsConnected] = useState(false);
const [error, setError] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const dataRef = useRef(data);
const {
isConnected: wsConnected,
error: wsError,
subscribeToCellUpdates,
subscribeToRowAdds,
subscribeToRowDeletes,
subscribeToErrors,
} = useRealtime();
// Ваша существующая функция форматирования
const formatedToSubRows = useCallback((depth, oldData) => {
if (depth === 0) return oldData;
const dataFiltered = oldData.filter((d) => d.depth < depth);
for (let i = 0; i < dataFiltered.length; i++) {
const curRow = dataFiltered[i];
if (curRow.depth !== depth - 1) continue;
const nextRow = dataFiltered[i + 1] || null;
const subRows = oldData.filter(
(d) =>
d.depth === depth &&
d.sort_order > curRow.sort_order &&
(!nextRow || d.sort_order < nextRow.sort_order),
);
curRow.subRows = subRows;
}
return formatedToSubRows(depth - 1, dataFiltered);
}, []);
// Загрузка начальных данных
useEffect(() => {
const loadInitialData = async () => {
try {
setIsLoading(true);
const res = await FormsSheetApi.get(formId, sheetName);
const mockData = res.result;
if (!mockData || mockData.length === 0) {
setData([]);
return;
}
const maxDepth = mockData.reduce((prev, current) => {
return (prev.depth || 0) > (current.depth || 0) ? prev : current;
}, { depth: 0 }).depth;
const formattedData = formatedToSubRows(maxDepth, mockData);
setData(formattedData);
} catch (err) {
console.error('Failed to load initial data:', err);
setError(err.message);
} finally {
setIsLoading(false);
}
};
loadInitialData();
}, [formId, sheetName, formatedToSubRows]);
useEffect(() => {
setIsConnected(wsConnected);
}, [wsConnected]);
useEffect(() => {
setError(wsError);
}, [wsError]);
useEffect(() => {
dataRef.current = data;
}, [data]);
// Подписка на обновления WebSocket
useEffect(() => {
if (!wsConnected) return;
const unsubscribeCellUpdates = subscribeToCellUpdates((updatedCells) => {
if (!updatedCells || updatedCells.length === 0) return;
setData(prevData => {
// Функция для обновления данных в иерархической структуре
const updateInHierarchy = (items) => {
return items.map(item => {
let updatedItem = { ...item };
// Обновляем ячейки текущего уровня
updatedCells.forEach(cell => {
if ((item.id || item.line_id) === cell.line_id) {
updatedItem[cell.column] = cell.value;
}
});
// Рекурсивно обновляем подстроки
if (item.subRows && item.subRows.length > 0) {
updatedItem.subRows = updateInHierarchy(item.subRows);
}
return updatedItem;
});
};
return updateInHierarchy(prevData);
});
});
const unsubscribeRowAdds = subscribeToRowAdds((newRow) => {
setData(prevData => {
// Преобразуем текущие данные в плоский массив
const flatData = flattenHierarchy(prevData);
// Добавляем новую строку
const updatedFlatData = [...flatData, newRow];
// Сортируем по sort_order
updatedFlatData.sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0));
// Восстанавливаем иерархию
const maxDepth = updatedFlatData.reduce((prev, current) => {
return (prev.depth || 0) > (current.depth || 0) ? prev : current;
}, { depth: 0 }).depth;
return formatedToSubRows(maxDepth, updatedFlatData);
});
});
const unsubscribeRowDeletes = subscribeToRowDeletes((deletedRowId) => {
setData(prevData => {
// Функция для удаления строки из иерархии
const deleteInHierarchy = (items) => {
return items
.filter(item => (item.id || item.line_id) !== deletedRowId)
.map(item => {
if (item.subRows && item.subRows.length > 0) {
return { ...item, subRows: deleteInHierarchy(item.subRows) };
}
return item;
});
};
return deleteInHierarchy(prevData);
});
});
const unsubscribeErrors = subscribeToErrors((err) => {
console.error('WebSocket error:', err);
setError(err);
setTimeout(() => setError(null), 5000);
});
return () => {
unsubscribeCellUpdates();
unsubscribeRowAdds();
unsubscribeRowDeletes();
unsubscribeErrors();
};
}, [wsConnected, subscribeToCellUpdates, subscribeToRowAdds, subscribeToRowDeletes, subscribeToErrors, formatedToSubRows]);
// Вспомогательная функция для преобразования иерархических данных в плоский массив
const flattenHierarchy = useCallback((items, result = []) => {
for (const item of items) {
const { subRows, ...rest } = item;
result.push(rest);
if (subRows && subRows.length > 0) {
flattenHierarchy(subRows, result);
}
}
return result;
}, []);
const updateData = useCallback((newData) => {
if (Array.isArray(newData) && !newData[0]?.subRows) {
const maxDepth = newData.reduce((prev, current) => {
return (prev.depth || 0) > (current.depth || 0) ? prev : current;
}, { depth: 0 }).depth;
const formattedData = formatedToSubRows(maxDepth, newData);
setData(formattedData);
} else {
setData(newData);
}
}, [formatedToSubRows]);
return {
data,
setData: updateData,
isConnected,
isLoading,
error,
};
};
export default useRealtimeData;

View File

@ -0,0 +1,114 @@
import { useEffect, useRef, useCallback, useState } from 'react';
export const useWebSocket = (url, onMessage) => {
const [isConnected, setIsConnected] = useState(false);
const [error, setError] = useState(null);
const wsRef = useRef(null);
const reconnectTimeoutRef = useRef(null);
const reconnectAttempts = useRef(0);
const connect = useCallback(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) return;
try {
console.log('Connecting to WebSocket:', url);
const ws = new WebSocket(url);
wsRef.current = ws;
ws.onopen = () => {
console.log('WebSocket connected successfully');
setIsConnected(true);
setError(null);
reconnectAttempts.current = 0;
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
console.log('WebSocket message received:', data);
onMessage(data);
} catch (err) {
console.error('Failed to parse WebSocket message:', err);
}
};
ws.onerror = (event) => {
console.error('WebSocket error:', event);
// Не закрываем соединение при ошибке, просто логируем
setError('Connection error occurred');
};
ws.onclose = (event) => {
console.log('WebSocket closed:', event.code, event.reason);
setIsConnected(false);
// Не переподключаемся если закрыто intentionally или с ошибкой авторизации
if (event.code === 1008 || event.code === 1000) {
console.log('Connection closed intentionally or auth error, not reconnecting');
return;
}
// Reconnect with exponential backoff
if (reconnectAttempts.current < 5) {
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts.current), 30000);
console.log(`Reconnecting in ${delay}ms...`);
reconnectTimeoutRef.current = setTimeout(() => {
reconnectAttempts.current++;
connect();
}, delay);
} else {
setError('Max reconnection attempts reached');
}
};
} catch (err) {
console.error('Failed to create WebSocket:', err);
setError(err.message);
}
}, [url, onMessage]);
const disconnect = useCallback(() => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
if (wsRef.current) {
wsRef.current.close(1000, 'Intentional disconnect');
wsRef.current = null;
}
setIsConnected(false);
}, []);
const sendMessage = useCallback((message) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify(message));
console.log('Message sent:', message);
return true;
}
console.warn('WebSocket is not connected, readyState:', wsRef.current?.readyState);
setError('Cannot send message: WebSocket not connected');
return false;
}, []);
const login = useCallback((token) => {
if (!token) {
console.warn('No token provided for login');
return false;
}
return sendMessage({
event: 'user_login',
data: { token }
});
}, [sendMessage]);
useEffect(() => {
connect();
return () => disconnect();
}, [connect, disconnect]);
return {
isConnected,
error,
sendMessage,
login,
disconnect
};
};

View File

@ -3,7 +3,7 @@
export { default as RealtimeTable } from './RealtimeTable';
export { default as Cell } from './Cell/Cell/Cell';
export { default as EditCell } from './Cell/EditCell/EditCell';
export { default as useRealtimeData } from './useRealtimeData';
export { default as useRealtimeData } from './hooks/useRealtimeData';
export { getTableColumns } from './tableColumns';
// Или можно экспортировать всё вместе

View File

@ -1,488 +0,0 @@
import { useState, useEffect, useCallback } from "react";
import { dataMock } from "../../mocks/mockTable";
import { FormsSheetApi } from "../../api/form_sheet";
const useRealtimeData = (formId, sheetName) => {
const [data, setData] = useState([]);
const formatedToSubRows = useCallback((depth, oldData) => {
if (depth == 0) return oldData;
const data = oldData.filter((d) => d.depth < depth);
for (let i = 0; i < data.length; i++) {
const curRow = data[i];
if (curRow.depth !== depth - 1) continue;
const nextRow = data[i + 1] || null;
const subRows = oldData.filter(
(d) =>
d.depth == depth &&
d.sort_order > curRow.sort_order &&
(!nextRow || d.sort_order < nextRow.sort_order),
);
curRow.subRows = subRows;
}
return formatedToSubRows(depth - 1, data);
}, []);
useEffect(() => {
const loadInitialData = async () => {
// const res = {
// success: true,
// message: null,
// result: [
// {
// row_type: "ROOT",
// depth: 0,
// data: {
// q1: {
// total: "1800",
// adj_rf: "2Не применимо",
// pay_ho: "3Не применимо",
// pay_rf: "4Не применимо",
// adj_ssp: "5Не применимо",
// booking: "6Нет данных",
// economy: "7800",
// pay_act: "8Не выполнена",
// pay_date: "91970-01-01",
// actual_m1: "10Нет факта",
// actual_m2: "11Нет факта",
// actual_m3: "12Нет факта",
// pay_amount: "13Не оплачено",
// rem_actual: "14800",
// adj_comment: "15Комментарий отсутствует",
// adj_current: "16Не требуется",
// adj_reserve: "17Не требуется",
// pay_comment: "18Комментарий по оплате отсутствует",
// rem_booking: "19800",
// transfer_q2: "20Нет переноса",
// transfer_q3: "21Нет переноса",
// transfer_q4: "22Нет переноса",
// actual_quarter: "23800",
// corrected_plan: "24800",
// },
// q2: {
// actual_m1: "actual_m1",
// actual_m2: "actual_m2",
// actual_m3: "actual_m3",
// actual_quarter: "actual_quarter",
// adj_comment: "adj_comment",
// adj_current: "adj_current",
// adj_reserve: "adj_reserve",
// adj_rf: "adj_rf",
// adj_ssp: "adj_ssp",
// booking: "booking",
// corrected_plan: "corrected_plan",
// economy: "economy",
// new_plan: "new_plan",
// pay_act: "pay_act",
// pay_amount: "pay_amount",
// pay_comment: "pay_comment",
// pay_date: "pay_date",
// pay_ho: "pay_ho",
// pay_rf: "pay_rf",
// rem_actual: "rem_actual",
// rem_booking: "rem_booking",
// rev_comment: "rev_comment",
// rev_eco: "rev_eco",
// rev_inc: "rev_inc",
// rev_item: "rev_item",
// rev_seq: "rev_seq",
// total: "total",
// transfer_q3: "transfer_q3",
// transfer_q4: "transfer_q4",
// },
// q3: {
// total: "800",
// adj_rf: "Не применимо",
// pay_ho: "Не применимо",
// pay_rf: "Не применимо",
// adj_ssp: "Не применимо",
// booking: "Нет данных",
// economy: "800",
// pay_act: "Не выполнена",
// rev_eco: "Нет экономии",
// rev_inc: "Нет увеличения",
// rev_seq: "Нет секвестра",
// new_plan: "800",
// pay_date: "1970-01-01",
// rev_item: "Нет изменений",
// actual_m1: "Нет факта",
// actual_m2: "Нет факта",
// actual_m3: "Нет факта",
// pay_amount: "Не оплачено",
// rem_actual: "800",
// adj_comment: "Комментарий отсутствует",
// adj_current: "Не требуется",
// adj_reserve: "Не требуется",
// pay_comment: "Комментарий по оплате отсутствует",
// rem_booking: "800",
// rev_comment: "Комментарий по пересмотру отсутствует",
// transfer_q4: "Нет переноса",
// actual_quarter: "800",
// corrected_plan: "800",
// },
// q4: {
// total: "800",
// adj_rf: "Не применимо",
// pay_ho: "Не применимо",
// pay_rf: "Не применимо",
// adj_ssp: "Не применимо",
// booking: "Нет данных",
// economy: "800",
// pay_act: "Не выполнена",
// rev_eco: "Нет экономии",
// rev_inc: "Нет увеличения",
// rev_seq: "Нет секвестра",
// new_plan: "800",
// pay_date: "1970-01-01",
// rev_item: "Нет изменений",
// actual_m1: "Нет факта",
// actual_m2: "Нет факта",
// actual_m3: "Нет факта",
// pay_amount: "Не оплачено",
// rem_actual: "800",
// actual_spod: "Нет данных СПОД",
// adj_comment: "Комментарий отсутствует",
// adj_current: "Не требуется",
// adj_reserve: "Не требуется",
// pay_comment: "Комментарий по оплате отсутствует",
// rem_booking: "800",
// rev_comment: "Комментарий по пересмотру отсутствует",
// actual_quarter: "800",
// corrected_plan: "800",
// },
// ckk: {
// q1: "Нет плана",
// q2: "Нет плана",
// q3: "Нет плана",
// q4: "Нет плана",
// ceiling: "Лимит не установлен",
// comment: "Комментарий по ЦКК отсутствует",
// deadline: "1970-01-01",
// proc_plan: "Нет плана процедур",
// proc_method: "Не определен",
// rf_schedule: "График не задан",
// },
// plan: {
// q1: "Нет плана",
// q2: "Нет плана",
// q3: "Нет плана",
// q4: "Нет плана",
// year: "800",
// comment: "Комментарий по плану отсутствует",
// },
// header: {
// name: "Прочие операционные расходы",
// year: 2026,
// item_id: "Нет ID",
// section: "4.00.0.",
// num_group: "Номер группы не задан",
// internal_order: "Нет внутреннего заказа",
// expense_item_id: 708,
// },
// totals: {
// pay_year: "Оплата за год не производилась",
// fact_year: "800",
// },
// line_id: "Нет ID строки",
// reserve: {
// q1: "Нет резерва",
// q2: "Нет резерва",
// q3: "Нет резерва",
// q4: "Нет резерва",
// year: "800",
// justification: "Обоснование резерва отсутствует",
// },
// approved: {
// q1: "800",
// q2: "800",
// q3: "800",
// q4: "800",
// year: "800",
// },
// collegial: {
// note: "Замечания коллегиального органа отсутствуют",
// approved: "Не утверждено",
// protocol: "Нет протокола",
// },
// allocation: {
// order: "Приказ не указан",
// property: "Свойства распределения не заданы",
// },
// sequestration: {
// q1: "Нет секвестра",
// q2: "Нет секвестра",
// q3: "Нет секвестра",
// q4: "Нет секвестра",
// year: "800",
// justification: "Обоснование секвестра отсутствует",
// },
// contract_detail: {
// q1: "Нет деталей",
// q2: "Нет деталей",
// q3: "Нет деталей",
// q4: "Нет деталей",
// act: "Акт не предоставлен",
// addenda: "Нет доп. соглашений",
// ceiling: "Потолок не установлен",
// comment: "Комментарий по контракту отсутствует",
// subject: "Предмет контракта не указан",
// currency: "RUB",
// deadline: "1970-01-01",
// vat_rate: "Нет ставки НДС",
// reference: "Ссылка отсутствует",
// rf_schedule: "График не задан",
// counterparty: "Контрагент не определен",
// exchange_rate: -1,
// amount_foreign: -1,
// payment_scheme: "Схема оплаты не определена",
// },
// contract_summary: {
// total: "Итог по контракту не подведен",
// comment: "Комментарий по сводке контракта отсутствует",
// deadline: "1970-01-01",
// future_y1: "Нет данных на след. год",
// future_y2: "Нет данных через год",
// other_ssp: "Нет данных по СПП",
// counterparty: "Контрагент не определен",
// },
// },
// sort_order: 1,
// },
// ],
// count: 38,
// };
// const res = {
// success: true,
// message: null,
// result: [
// {
// row_type: "ROOT",
// depth: 0,
// data: {
// q1: {
// total: 0,
// adj_ssp: "adj_ssp_mock",
// booking: "booking_mock",
// pay_act: "pay_act_mock",
// pay_date: "2025-01-15",
// actual_m1: "1000.00",
// actual_m2: "1200.00",
// actual_m3: "1100.00",
// pay_amount: "3300.00",
// adj_comment: "adj_comment_mock",
// adj_current: "adj_current_mock",
// adj_reserve: "adj_reserve_mock",
// pay_comment: "pay_comment_mock",
// transfer_q2: "transfer_q2_mock",
// transfer_q3: "transfer_q3_mock",
// transfer_q4: "transfer_q4_mock",
// transfer_econ: "transfer_econ_mock",
// actual_quarter: 3300,
// corrected_plan: 100,
// transfer_far_comment: "transfer_far_comment_mock",
// residual_after_actual: 100,
// transfer_next_comment: "transfer_next_comment_mock",
// residual_after_booking: 100,
// transfer_q2_delay_acts: "transfer_q2_delay_acts_mock",
// transfer_q2_economy_rf: "transfer_q2_economy_rf_mock",
// transfer_q2_delay_procurement:
// "transfer_q2_delay_procurement_mock",
// },
// q2: {
// total: 0,
// adj_ssp: "adj_ssp_mock_q2",
// booking: "booking_mock_q2",
// pay_act: "pay_act_mock_q2",
// new_plan: 200,
// pay_date: "2025-04-20",
// actual_m1: "2000.00",
// actual_m2: "2100.00",
// actual_m3: "1900.00",
// pay_amount: "6000.00",
// adj_comment: "adj_comment_mock_q2",
// adj_current: "adj_current_mock_q2",
// adj_reserve: "adj_reserve_mock_q2",
// pay_comment: "pay_comment_mock_q2",
// transfer_q3: "transfer_q3_mock",
// transfer_q4: "transfer_q4_mock",
// revision_inc: "revision_inc_mock",
// revision_seq: "revision_seq_mock",
// target_change: "target_change_mock",
// transfer_econ: "transfer_econ_mock_q2",
// actual_quarter: 6000,
// corrected_plan: 200,
// base_correction: "base_correction_mock",
// revision_comment: "revision_comment_mock",
// transfer_far_comment: "transfer_far_comment_mock_q2",
// residual_after_actual: 200,
// transfer_next_comment: "transfer_next_comment_mock_q2",
// residual_after_booking: 200,
// transfer_q3_delay_acts: "transfer_q3_delay_acts_mock",
// transfer_q3_economy_rf: "transfer_q3_economy_rf_mock",
// base_correction_comment: "base_correction_comment_mock",
// transfer_q3_delay_procurement:
// "transfer_q3_delay_procurement_mock",
// },
// q3: {
// total: 0,
// adj_ssp: "adj_ssp_mock_q3",
// booking: "booking_mock_q3",
// pay_act: "pay_act_mock_q3",
// new_plan: 100,
// pay_date: "2025-07-10",
// actual_m1: "900.00",
// actual_m2: "850.00",
// actual_m3: "950.00",
// pay_amount: "2700.00",
// adj_comment: "adj_comment_mock_q3",
// adj_current: "adj_current_mock_q3",
// adj_reserve: "adj_reserve_mock_q3",
// pay_comment: "pay_comment_mock_q3",
// transfer_q4: "transfer_q4_mock_q3",
// revision_inc: "revision_inc_mock_q3",
// revision_seq: "revision_seq_mock_q3",
// target_change: "target_change_mock_q3",
// transfer_econ: "transfer_econ_mock_q3",
// actual_quarter: 2700,
// corrected_plan: 100,
// base_correction: "base_correction_mock_q3",
// revision_comment: "revision_comment_mock_q3",
// residual_after_actual: 100,
// transfer_next_comment: "transfer_next_comment_mock_q3",
// residual_after_booking: 100,
// transfer_q4_delay_acts: "transfer_q4_delay_acts_mock",
// transfer_q4_economy_rf: "transfer_q4_economy_rf_mock",
// base_correction_comment: "base_correction_comment_mock_q3",
// transfer_q4_delay_procurement:
// "transfer_q4_delay_procurement_mock",
// },
// q4: {
// total: "total_mock_q4",
// adj_ssp: "adj_ssp_mock_q4",
// booking: "booking_mock_q4",
// pay_act: "pay_act_mock_q4",
// new_plan: 200,
// pay_date: "2025-10-05",
// actual_m1: "1500.00",
// actual_m2: "1600.00",
// actual_m3: "1400.00",
// pay_amount: "4500.00",
// actual_spod: "actual_spod_mock",
// adj_comment: "adj_comment_mock_q4",
// adj_current: "adj_current_mock_q4",
// adj_reserve: "adj_reserve_mock_q4",
// pay_comment: "pay_comment_mock_q4",
// revision_inc: "revision_inc_mock_q4",
// revision_seq: "revision_seq_mock_q4",
// target_change: "target_change_mock_q4",
// transfer_econ: "transfer_econ_mock_q4",
// actual_quarter: 4500,
// corrected_plan: 200,
// base_correction: "base_correction_mock_q4",
// revision_comment: "revision_comment_mock_q4",
// residual_after_actual: 200,
// residual_after_booking: 200,
// base_correction_comment: "base_correction_comment_mock_q4",
// },
// plan: {
// q1: "plan_q1_mock",
// q2: "plan_q2_mock",
// q3: "plan_q3_mock",
// q4: "plan_q4_mock",
// year: 2025,
// comment: "plan_comment_mock",
// },
// header: {
// name: "Административно-хозяйственные расходы",
// vsp_id: "VSP_001",
// item_id: "ITEM_001",
// num_group: "GRP_001",
// vsp_address: "г. Москва, ул. Примерная, д. 1",
// section_code: "1.00.0.",
// expense_item_id: 1,
// },
// totals: {
// pay_year: 16500,
// fact_year: 16500,
// economy_year: 200,
// },
// booking: {
// y2026: {
// q1: "booking_y2026_q1_mock",
// q2: "booking_y2026_q2_mock",
// q3: "booking_y2026_q3_mock",
// q4: "booking_y2026_q4_mock",
// },
// y2027: {
// q1: "booking_y2027_q1_mock",
// q2: "booking_y2027_q2_mock",
// q3: "booking_y2027_q3_mock",
// q4: "booking_y2027_q4_mock",
// },
// },
// line_id: "LINE_001",
// seq_ssp: {
// q1: "seq_ssp_q1_mock",
// q2: "seq_ssp_q2_mock",
// q3: "seq_ssp_q3_mock",
// q4: "seq_ssp_q4_mock",
// year: 600,
// justification: "seq_ssp_justification_mock",
// },
// approved: {
// q1: 100,
// q2: 200,
// q3: 100,
// q4: 200,
// year: 600,
// },
// contract: {
// act: "contract_act_mock",
// date: "2025-01-01",
// scheme: "contract_scheme_mock",
// ceiling: "1000000.00",
// comment: "contract_comment_mock",
// subject: "contract_subject_mock",
// currency: "RUB",
// deadline: "2025-12-31",
// vat_rate: "20%",
// reference: "contract_ref_001",
// counterparty: "ООО Контрагент",
// },
// seq_dfip: {
// q1: 100,
// q2: 200,
// q3: 100,
// q4: 200,
// year: 600,
// justification: "seq_dfip_justification_mock",
// },
// },
// sort_order: 1,
// },
// ],
// count: 1,
// };
const res = await FormsSheetApi.get(formId, sheetName);
const mockData = res.result;
const maxDepth = mockData.reduce((prev, current) => {
return prev.depth > current.depth ? prev : current;
}).depth;
const data = formatedToSubRows(maxDepth, mockData);
setData(data);
};
loadInitialData();
}, [formatedToSubRows]);
return {
data,
setData,
};
};
export default useRealtimeData;

View File

@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
import { PageContainer } from '../../StyledForPage';
import { HeaderSwitch } from '../../../components/HeaderMenu/HeaderSwitch';
import { SwitchUserSsp } from '../../../components/AdminPanel/SwitchUserSsp';
import { ROLES_NAME_ID } from '../../../constants';
import { ROLES_ID_RUSSIAN_NAME_ARRAY, ROLES_NAME_ID } from '../../../constants';
import { useAuth } from '../../../app/context/AuthProvider';
import { Box, TextField, Autocomplete, FormLabel } from '@mui/material';
import SearchComponent from '../../../components/common/SearchComponent';
@ -33,7 +33,6 @@ const UsersPage = () => {
const formatUsers = async (users) => {
const userPromises = users.map(async (user) => {
// if (user.role_id == ROLES_NAME_ID['executor_dfip']) {
try {
const data = await SspApi.getManySsp(user.id);
if (!data.success) {
@ -44,7 +43,6 @@ const UsersPage = () => {
} catch (error) {
toast.error('Ошибка получения пользователей');
}
// }
user.ssps_data = user.ssp
.map((sspId) => deps.find((d) => d.id === sspId) || null)
@ -67,13 +65,9 @@ const UsersPage = () => {
const activeDeps = data.result.filter((v) => v.is_active);
setDeps(activeDeps);
});
UsersApi.getAllRoles().then((data) => {
if (!data.success) {
toast.error('Ошибка получения Ролей ' + data.message);
return;
}
setRoles(data.result);
});
setRoles(ROLES_ID_RUSSIAN_NAME_ARRAY);
}, []);
useEffect(() => {
@ -214,7 +208,7 @@ const UsersPage = () => {
update: [sspId],
delete:
user.role_id === ROLES_NAME_ID.executor_rf &&
user.ssp[0] !== undefined
user.ssp[0] !== undefined
? [user.ssp[0]]
: [],
};
@ -229,12 +223,13 @@ const UsersPage = () => {
};
if (!user || user.role_id != ROLES_NAME_ID.admin) return;
return (
<>
<PageContainer>
<HeaderSwitch></HeaderSwitch>
<Box display="flex" justifyContent="space-between">
<SwitchUserSsp></SwitchUserSsp>
<HeaderSwitch />
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<SwitchUserSsp />
<Box sx={{ display: 'flex', gap: '1rem', alignItems: 'end' }}>
<PrimaryButton
onClick={handleOpenModalAddUsers}
@ -245,7 +240,7 @@ const UsersPage = () => {
</PrimaryButton>
</Box>
</Box>
<Box display="flex" justifyContent="space-between">
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', gap: '1rem', alignItems: 'end' }}>
<SearchComponent
placeholder="Поиск по пользователям..."
@ -391,7 +386,7 @@ const UsersPage = () => {
onClose={handleCloseModalAddUsers}
sspOptions={deps}
onAddUser={handleAddUserToList}
></ModalAddUsers>
/>
</>
);
};