647 lines
17 KiB
JavaScript
647 lines
17 KiB
JavaScript
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
||
import { toast } from 'react-toastify';
|
||
import { useWebSocket } from '../hooks/useWebSocket';
|
||
import { buildCellUpdatedMessage } from '../utils/messageUtils';
|
||
import { getRowId } from '../utils/rowUtils';
|
||
import { getWsErrorMessage } from '../utils/wsErrorUtils';
|
||
|
||
const RealtimeActionsContext = createContext(null);
|
||
const RealtimeConnectionContext = createContext(null);
|
||
const RealtimeVspOptionsContext = createContext(null);
|
||
const LockedCellsStoreContext = createContext(null);
|
||
|
||
const getColumnRequestKey = (column) => {
|
||
const columnKey = column?.columnDef?.id ?? column?.id;
|
||
if (columnKey === null || columnKey === undefined) return '';
|
||
|
||
const normalizedColumnKey = String(columnKey);
|
||
return normalizedColumnKey.startsWith('data.') ? normalizedColumnKey.slice(5) : normalizedColumnKey;
|
||
};
|
||
|
||
const useRequiredContext = (context, hookName) => {
|
||
const value = useContext(context);
|
||
if (value === null) {
|
||
throw new Error(`${hookName} must be used within RealtimeProvider`);
|
||
}
|
||
return value;
|
||
};
|
||
|
||
const createLockedCellsStore = () => {
|
||
let cells = new Set();
|
||
const listenersByCell = new Map();
|
||
|
||
const subscribeCell = (cellKey, listener) => {
|
||
const listeners = listenersByCell.get(cellKey) || new Set();
|
||
listeners.add(listener);
|
||
listenersByCell.set(cellKey, listeners);
|
||
|
||
return () => {
|
||
listeners.delete(listener);
|
||
if (listeners.size === 0) listenersByCell.delete(cellKey);
|
||
};
|
||
};
|
||
|
||
const setCells = (nextValue) => {
|
||
const currentCells = [...cells];
|
||
const resolvedValue = typeof nextValue === 'function' ? nextValue(currentCells) : nextValue;
|
||
const nextCells = new Set(resolvedValue || []);
|
||
const changedCells = new Set([
|
||
...[...cells].filter((cellKey) => !nextCells.has(cellKey)),
|
||
...[...nextCells].filter((cellKey) => !cells.has(cellKey)),
|
||
]);
|
||
|
||
if (changedCells.size === 0) return;
|
||
cells = nextCells;
|
||
changedCells.forEach((cellKey) => {
|
||
listenersByCell.get(cellKey)?.forEach((listener) => listener());
|
||
});
|
||
};
|
||
|
||
return {
|
||
subscribeCell,
|
||
setCells,
|
||
hasCell: (cellKey) => cells.has(cellKey),
|
||
getCells: () => [...cells],
|
||
};
|
||
};
|
||
|
||
export const useRealtimeActions = () => useRequiredContext(RealtimeActionsContext, 'useRealtimeActions');
|
||
|
||
export const useRealtimeConnection = () => useRequiredContext(RealtimeConnectionContext, 'useRealtimeConnection');
|
||
|
||
export const useVspOptions = () => useRequiredContext(RealtimeVspOptionsContext, 'useVspOptions');
|
||
|
||
export const useCellLock = (cellKey) => {
|
||
const store = useRequiredContext(LockedCellsStoreContext, 'useCellLock');
|
||
const subscribe = useCallback((listener) => store.subscribeCell(cellKey, listener), [cellKey, store]);
|
||
const getSnapshot = useCallback(() => store.hasCell(cellKey), [cellKey, store]);
|
||
|
||
return useSyncExternalStore(subscribe, getSnapshot, () => false);
|
||
};
|
||
|
||
// AURORA-1891: имперактивная проверка lockedCellsStore.hasCell(key) из
|
||
// обработчика клика/клавиатуры на ячейке (muiTableBodyCellProps в
|
||
// RealtimeTable.jsx) — там нужен сам стор (не подписка на конкретную ячейку,
|
||
// как useCellLock выше), чтобы в момент события синхронно узнать, занята ли
|
||
// именно ЭТА ячейка, без лишнего ре-рендера на каждое изменение чужих блокировок.
|
||
export const useLockedCellsStore = () => useRequiredContext(LockedCellsStoreContext, 'useLockedCellsStore');
|
||
|
||
export const useRealtime = () => {
|
||
const actions = useRealtimeActions();
|
||
const connection = useRealtimeConnection();
|
||
const vspOptions = useVspOptions();
|
||
|
||
return { ...actions, ...connection, vspOptions };
|
||
};
|
||
|
||
export const RealtimeProvider = ({ children, formId, sheetName, direction, year, isProject = false }) => {
|
||
// Формируем URL (кастомные секреты на фронте пока недоступны, делаем через REACT_APP_ROOT_PATH)
|
||
const wsUrl = `${(
|
||
process.env.REACT_APP_API_URL || process.env.REACT_APP_API_URL || process.env.REACT_APP_ROOT_PATH || 'ws://localhost:8000'
|
||
).replace(/^http/, 'ws')}${
|
||
!isProject
|
||
? `/api/v1/ws/form/${formId}/sheet/${sheetName}${direction ? `?direction=${direction}` : ''}`
|
||
: `/api/v1/ws/projects/${formId}/report/${year}/${sheetName}`
|
||
}`;
|
||
|
||
const [vspOptions, setVspOptions] = useState([]);
|
||
const lockedCellsStoreRef = useRef(null);
|
||
if (lockedCellsStoreRef.current === null) {
|
||
lockedCellsStoreRef.current = createLockedCellsStore();
|
||
}
|
||
const lockedCellsStore = lockedCellsStoreRef.current;
|
||
|
||
const handleMessage = useCallback((data) => {
|
||
// Обработка ошибок
|
||
if (data.error) {
|
||
console.log(data.error);
|
||
toast.error(getWsErrorMessage(data.error));
|
||
onErrorRef.current?.(data.error);
|
||
// AURORA-1980: окну переименования нужно полное сообщение (event + data),
|
||
// а не только текст ошибки.
|
||
if (data.event === 'program_updated' || data.event === 'project_updated') {
|
||
onNodeRenameResultRef.current?.(data);
|
||
}
|
||
return;
|
||
}
|
||
|
||
switch (data.event) {
|
||
case 'cell_edit_start':
|
||
console.log('Cell edit started:', data);
|
||
if (onCellEditStartRef.current) {
|
||
onCellEditStartRef.current(data);
|
||
}
|
||
break;
|
||
|
||
case 'cell_edit_end':
|
||
console.log('Cell edit ended:', data);
|
||
if (onCellEditEndRef.current) {
|
||
onCellEditEndRef.current(data);
|
||
}
|
||
break;
|
||
|
||
case 'cell_updated':
|
||
if (data.result && onCellUpdateRef.current) {
|
||
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);
|
||
}
|
||
break;
|
||
|
||
case 'row_deleted':
|
||
if (data.result && onRowDeleteRef.current) {
|
||
onRowDeleteRef.current(data);
|
||
}
|
||
break;
|
||
|
||
case 'program_added':
|
||
console.log('Program added:', data);
|
||
if (data.result && onProgramAddRef.current) {
|
||
onProgramAddRef.current(data);
|
||
}
|
||
break;
|
||
|
||
case 'project_added':
|
||
console.log('Project added:', data);
|
||
if (data.result && onProjectAddRef.current) {
|
||
onProjectAddRef.current(data);
|
||
}
|
||
break;
|
||
|
||
// AURORA-1978: удаление программы/проекта формы 4.
|
||
case 'program_deleted':
|
||
case 'project_deleted':
|
||
if (data.result && onNodeDeleteRef.current) {
|
||
onNodeDeleteRef.current(data);
|
||
}
|
||
break;
|
||
|
||
// AURORA-1980: переименование программы/проекта формы 4.
|
||
case 'program_updated':
|
||
case 'project_updated':
|
||
if (data.result) {
|
||
onNodeUpdateRef.current?.(data);
|
||
onNodeRenameResultRef.current?.(data);
|
||
}
|
||
break;
|
||
|
||
default:
|
||
console.log('Unknown event:', data.event);
|
||
}
|
||
}, []);
|
||
|
||
const { isConnectedRef, isConnected, sendMessage } = useWebSocket(wsUrl, handleMessage);
|
||
|
||
const onCellUpdateRef = useRef(null);
|
||
const onRowAddRef = useRef(null);
|
||
const onRowDeleteRef = useRef(null);
|
||
const onProgramAddRef = useRef(null);
|
||
const onProjectAddRef = useRef(null);
|
||
const onNodeDeleteRef = useRef(null);
|
||
const onNodeUpdateRef = useRef(null);
|
||
const onNodeRenameResultRef = useRef(null);
|
||
const onErrorRef = useRef(null);
|
||
const onAuthSuccessRef = useRef(null);
|
||
const onCellEditStartRef = useRef(null);
|
||
const onCellEditEndRef = useRef(null);
|
||
const authAttemptedRef = useRef(false);
|
||
|
||
// Функция для получения токена из localStorage
|
||
const getAccessToken = useCallback(() => {
|
||
try {
|
||
const token = localStorage.getItem('access_token');
|
||
|
||
if (!token) {
|
||
console.warn('No access token found in localStorage');
|
||
return null;
|
||
}
|
||
|
||
return token;
|
||
} catch (error) {
|
||
console.error('Error reading token from localStorage:', error);
|
||
return null;
|
||
}
|
||
}, []);
|
||
|
||
const sendLogin = useCallback(() => {
|
||
const token = getAccessToken();
|
||
if (!token) {
|
||
console.error('Cannot login: no token available');
|
||
onErrorRef.current?.('Токен авторизации не найден');
|
||
return false;
|
||
}
|
||
|
||
const loginMessage = {
|
||
event: 'user_login',
|
||
data: { token },
|
||
};
|
||
|
||
return sendMessage(loginMessage);
|
||
}, [getAccessToken, sendMessage]);
|
||
|
||
useEffect(() => {
|
||
if (isConnected && !authAttemptedRef.current) {
|
||
authAttemptedRef.current = true;
|
||
console.log('WebSocket connected, sending login...');
|
||
const timeoutId = setTimeout(() => {
|
||
sendLogin();
|
||
}, 100);
|
||
return () => clearTimeout(timeoutId);
|
||
}
|
||
}, [isConnected, sendLogin]);
|
||
|
||
useEffect(() => {
|
||
if (!isConnected) {
|
||
authAttemptedRef.current = false;
|
||
}
|
||
}, [isConnected]);
|
||
|
||
const addCommonField = useCallback(
|
||
(message) => {
|
||
if (isProject) {
|
||
message.report_type = sheetName;
|
||
message.project_id = formId;
|
||
message.year = year;
|
||
} else {
|
||
message.sheet = sheetName;
|
||
message.form_id = formId;
|
||
message.direction = direction;
|
||
}
|
||
return message;
|
||
},
|
||
[direction, formId, isProject, sheetName, year],
|
||
);
|
||
|
||
// Функция для начала редактирования ячейки
|
||
const startEditing = useCallback(
|
||
(row, column) => {
|
||
if (!isConnectedRef.current) {
|
||
onErrorRef.current?.('WebSocket не подключен');
|
||
return false;
|
||
}
|
||
|
||
const lineId = row.id || null;
|
||
const columnKey = getColumnRequestKey(column);
|
||
|
||
const message = {
|
||
event: 'cell_edit_start',
|
||
data: {
|
||
line_id: lineId,
|
||
column: columnKey,
|
||
},
|
||
};
|
||
|
||
const sent = sendMessage(message);
|
||
if (!sent) {
|
||
return false;
|
||
}
|
||
|
||
return true;
|
||
},
|
||
[isConnectedRef, sendMessage],
|
||
);
|
||
|
||
// Функция для завершения редактирования ячейки
|
||
const endEditing = useCallback(
|
||
(row, column) => {
|
||
if (!isConnectedRef.current) {
|
||
onErrorRef.current?.('WebSocket не подключен');
|
||
return false;
|
||
}
|
||
|
||
const lineId = row.id || null;
|
||
const columnKey = getColumnRequestKey(column);
|
||
|
||
const message = {
|
||
event: 'cell_edit_end',
|
||
data: {
|
||
line_id: lineId,
|
||
column: columnKey,
|
||
},
|
||
};
|
||
|
||
const sent = sendMessage(message);
|
||
if (!sent) {
|
||
return false;
|
||
}
|
||
|
||
return true;
|
||
},
|
||
[isConnectedRef, sendMessage],
|
||
);
|
||
|
||
const updateCell = useCallback(
|
||
(row, column, value) => {
|
||
if (!isConnectedRef.current) {
|
||
onErrorRef.current?.('WebSocket не подключен');
|
||
return false;
|
||
}
|
||
|
||
const lineId = getRowId(row);
|
||
const columnKey = getColumnRequestKey(column);
|
||
const message = buildCellUpdatedMessage({ lineId, lineIdCode: row.id, column: columnKey, value });
|
||
const sent = sendMessage(message);
|
||
|
||
if (!sent) {
|
||
return false;
|
||
}
|
||
|
||
return true;
|
||
},
|
||
[isConnectedRef, sendMessage],
|
||
);
|
||
|
||
const addRow = useCallback(
|
||
(rowData) => {
|
||
if (!isConnectedRef.current) {
|
||
onErrorRef.current?.('Нет подключения или авторизации');
|
||
return false;
|
||
}
|
||
|
||
const message = {
|
||
event: 'row_added',
|
||
data: rowData,
|
||
};
|
||
addCommonField(message);
|
||
|
||
return sendMessage(message);
|
||
},
|
||
[addCommonField, isConnectedRef, sendMessage],
|
||
);
|
||
|
||
const addProject = useCallback(
|
||
(rowData) => {
|
||
if (!isConnectedRef.current) {
|
||
onErrorRef.current?.('Нет подключения или авторизации');
|
||
return false;
|
||
}
|
||
|
||
const message = {
|
||
event: 'project_added',
|
||
data: rowData,
|
||
};
|
||
addCommonField(message);
|
||
|
||
return sendMessage(message);
|
||
},
|
||
[addCommonField, isConnectedRef, sendMessage],
|
||
);
|
||
|
||
const addProgram = useCallback(
|
||
(rowData) => {
|
||
if (!isConnectedRef.current) {
|
||
onErrorRef.current?.('Нет подключения или авторизации');
|
||
return false;
|
||
}
|
||
|
||
const message = {
|
||
event: 'program_added',
|
||
data: rowData,
|
||
};
|
||
addCommonField(message);
|
||
|
||
return sendMessage(message);
|
||
},
|
||
[addCommonField, isConnectedRef, sendMessage],
|
||
);
|
||
|
||
const deleteRow = useCallback(
|
||
(rowId) => {
|
||
if (!isConnectedRef.current) {
|
||
onErrorRef.current?.('Нет подключения или авторизации');
|
||
return false;
|
||
}
|
||
|
||
const message = {
|
||
event: 'row_deleted',
|
||
data: { row_id: rowId },
|
||
};
|
||
addCommonField(message);
|
||
|
||
return sendMessage(message);
|
||
},
|
||
[addCommonField, isConnectedRef, sendMessage],
|
||
);
|
||
|
||
// AURORA-1978: программа/проект формы 4 удаляются своими событиями —
|
||
// у их строк нет line_id, row_deleted для них не подходит.
|
||
const deleteProgram = useCallback(
|
||
(programId) => {
|
||
if (!isConnectedRef.current) {
|
||
onErrorRef.current?.('Нет подключения или авторизации');
|
||
return false;
|
||
}
|
||
|
||
const message = {
|
||
event: 'program_deleted',
|
||
data: { program_id: programId },
|
||
};
|
||
addCommonField(message);
|
||
|
||
return sendMessage(message);
|
||
},
|
||
[addCommonField, isConnectedRef, sendMessage],
|
||
);
|
||
|
||
const deleteProject = useCallback(
|
||
(projectId) => {
|
||
if (!isConnectedRef.current) {
|
||
onErrorRef.current?.('Нет подключения или авторизации');
|
||
return false;
|
||
}
|
||
|
||
const message = {
|
||
event: 'project_deleted',
|
||
data: { project_id: projectId },
|
||
};
|
||
addCommonField(message);
|
||
|
||
return sendMessage(message);
|
||
},
|
||
[addCommonField, isConnectedRef, sendMessage],
|
||
);
|
||
|
||
// AURORA-1980: переименование программы/проекта формы 4.
|
||
const renameNode = useCallback(
|
||
(kind, id, name) => {
|
||
if (!isConnectedRef.current) {
|
||
onErrorRef.current?.('Нет подключения или авторизации');
|
||
return false;
|
||
}
|
||
|
||
const message = {
|
||
event: `${kind}_updated`,
|
||
data: { [`${kind}_id`]: id, name },
|
||
};
|
||
addCommonField(message);
|
||
|
||
return sendMessage(message);
|
||
},
|
||
[addCommonField, isConnectedRef, 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 subscribeToProgramAdds = useCallback((callback) => {
|
||
onProgramAddRef.current = callback;
|
||
return () => {
|
||
onProgramAddRef.current = null;
|
||
};
|
||
}, []);
|
||
|
||
const subscribeToProjectAdds = useCallback((callback) => {
|
||
onProjectAddRef.current = callback;
|
||
return () => {
|
||
onProjectAddRef.current = null;
|
||
};
|
||
}, []);
|
||
|
||
const subscribeToNodeDeletes = useCallback((callback) => {
|
||
onNodeDeleteRef.current = callback;
|
||
return () => {
|
||
onNodeDeleteRef.current = null;
|
||
};
|
||
}, []);
|
||
|
||
const subscribeToNodeUpdates = useCallback((callback) => {
|
||
onNodeUpdateRef.current = callback;
|
||
return () => {
|
||
onNodeUpdateRef.current = null;
|
||
};
|
||
}, []);
|
||
|
||
const subscribeToNodeRenameResult = useCallback((callback) => {
|
||
onNodeRenameResultRef.current = callback;
|
||
return () => {
|
||
onNodeRenameResultRef.current = null;
|
||
};
|
||
}, []);
|
||
|
||
const subscribeToErrors = useCallback((callback) => {
|
||
onErrorRef.current = callback;
|
||
return () => {
|
||
onErrorRef.current = null;
|
||
};
|
||
}, []);
|
||
|
||
const subscribeToAuthSuccess = useCallback((callback) => {
|
||
onAuthSuccessRef.current = callback;
|
||
return () => {
|
||
onAuthSuccessRef.current = null;
|
||
};
|
||
}, []);
|
||
|
||
const subscribeToCellEditStart = useCallback((callback) => {
|
||
onCellEditStartRef.current = callback;
|
||
return () => {
|
||
onCellEditStartRef.current = null;
|
||
};
|
||
}, []);
|
||
|
||
const subscribeToCellEditEnd = useCallback((callback) => {
|
||
onCellEditEndRef.current = callback;
|
||
return () => {
|
||
onCellEditEndRef.current = null;
|
||
};
|
||
}, []);
|
||
|
||
const retryLogin = useCallback(() => {
|
||
if (isConnectedRef.current) {
|
||
authAttemptedRef.current = false;
|
||
sendLogin();
|
||
}
|
||
}, [isConnectedRef, sendLogin]);
|
||
|
||
const actions = useMemo(
|
||
() => ({
|
||
startEditing,
|
||
endEditing,
|
||
updateCell,
|
||
addRow,
|
||
addProgram,
|
||
addProject,
|
||
deleteRow,
|
||
deleteProgram,
|
||
deleteProject,
|
||
renameNode,
|
||
subscribeToRowAdds,
|
||
subscribeToRowDeletes,
|
||
subscribeToProgramAdds,
|
||
subscribeToProjectAdds,
|
||
subscribeToNodeDeletes,
|
||
subscribeToNodeUpdates,
|
||
subscribeToNodeRenameResult,
|
||
subscribeToErrors,
|
||
subscribeToAuthSuccess,
|
||
subscribeToCellEditStart,
|
||
subscribeToCellEditEnd,
|
||
subscribeToCellUpdates,
|
||
retryLogin,
|
||
setLockedCells: lockedCellsStore.setCells,
|
||
setVspOptions,
|
||
}),
|
||
[
|
||
addProgram,
|
||
addProject,
|
||
addRow,
|
||
deleteProgram,
|
||
deleteProject,
|
||
deleteRow,
|
||
endEditing,
|
||
renameNode,
|
||
lockedCellsStore,
|
||
retryLogin,
|
||
startEditing,
|
||
subscribeToAuthSuccess,
|
||
subscribeToCellEditEnd,
|
||
subscribeToCellEditStart,
|
||
subscribeToCellUpdates,
|
||
subscribeToErrors,
|
||
subscribeToNodeDeletes,
|
||
subscribeToNodeRenameResult,
|
||
subscribeToNodeUpdates,
|
||
subscribeToProgramAdds,
|
||
subscribeToProjectAdds,
|
||
subscribeToRowAdds,
|
||
subscribeToRowDeletes,
|
||
updateCell,
|
||
],
|
||
);
|
||
|
||
const connection = useMemo(() => ({ isConnected }), [isConnected]);
|
||
|
||
return (
|
||
<RealtimeActionsContext.Provider value={actions}>
|
||
<RealtimeConnectionContext.Provider value={connection}>
|
||
<RealtimeVspOptionsContext.Provider value={vspOptions}>
|
||
<LockedCellsStoreContext.Provider value={lockedCellsStore}>{children}</LockedCellsStoreContext.Provider>
|
||
</RealtimeVspOptionsContext.Provider>
|
||
</RealtimeConnectionContext.Provider>
|
||
</RealtimeActionsContext.Provider>
|
||
);
|
||
};
|