Andrey Epifancev 78227b46c0 AURORA-1980: переименование программы и проекта
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 22:32:42 +04:00

647 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
};