import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; import { toast } from 'react-toastify'; import { useWebSocket } from '../hooks/useWebSocket'; import { getRowId } from '../utils/rowUtils'; 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); }; 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.message); toast.error(`Server error:${data.error.message}` || ''); onErrorRef.current?.(data.error); 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; 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 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 = { event: 'cell_updated', data: { line_id: lineId, line_id_code: row.id, column: columnKey, value: 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], ); 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 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, subscribeToRowAdds, subscribeToRowDeletes, subscribeToProgramAdds, subscribeToProjectAdds, subscribeToErrors, subscribeToAuthSuccess, subscribeToCellEditStart, subscribeToCellEditEnd, subscribeToCellUpdates, retryLogin, setLockedCells: lockedCellsStore.setCells, setVspOptions, }), [ addProgram, addProject, addRow, deleteRow, endEditing, lockedCellsStore, retryLogin, startEditing, subscribeToAuthSuccess, subscribeToCellEditEnd, subscribeToCellEditStart, subscribeToCellUpdates, subscribeToErrors, subscribeToProgramAdds, subscribeToProjectAdds, subscribeToRowAdds, subscribeToRowDeletes, updateCell, ], ); const connection = useMemo(() => ({ isConnected }), [isConnected]); return ( {children} ); };