ws
This commit is contained in:
parent
bc05bc8154
commit
93881c7194
@ -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);
|
||||
|
||||
@ -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}
|
||||
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={{
|
||||
backgroundColor: '#ffeb3b',
|
||||
color: '#000',
|
||||
fontWeight: 'bold',
|
||||
padding: '0 2px',
|
||||
borderRadius: '2px',
|
||||
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;
|
||||
@ -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);
|
||||
176
web/src/components/RealtimeTable/contexts/RealtimeContext.js
Normal file
176
web/src/components/RealtimeTable/contexts/RealtimeContext.js
Normal 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>
|
||||
);
|
||||
};
|
||||
122
web/src/components/RealtimeTable/hooks/useCellLocks.js
Normal file
122
web/src/components/RealtimeTable/hooks/useCellLocks.js
Normal 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
|
||||
};
|
||||
};
|
||||
202
web/src/components/RealtimeTable/hooks/useRealtimeData.js
Normal file
202
web/src/components/RealtimeTable/hooks/useRealtimeData.js
Normal 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;
|
||||
114
web/src/components/RealtimeTable/hooks/useWebSocket.js
Normal file
114
web/src/components/RealtimeTable/hooks/useWebSocket.js
Normal 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
|
||||
};
|
||||
};
|
||||
@ -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';
|
||||
|
||||
// Или можно экспортировать всё вместе
|
||||
|
||||
@ -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;
|
||||
@ -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(() => {
|
||||
@ -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>
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user