Andrey Epifancev 3bee5e795f feat(formulas): маркер fx у ячейки, формула с сервера в редакторе, без localStorage (AURORA-1896)
- RealtimeContext: стор карты формул (formulaStore), загрузка при открытии листа и
  переподключении (формы и отчёты проектов), обновление на каждом cell_updated,
  защита от устаревших ответов при смене листа; useCellFormula; updateCell(..., { formula })
- Cell: маркер fx только при актуальной формуле, подсказка с единицей при displayFactor != 1
- EditCell: начальный текст «=выражение», выражение уходит в data.formula, без localStorage;
  ввод «100+50» без «=» не отправляется (тост «Формула начинается с =»)
- удалены resolveEditorState и clearLegacyFormulaKeys; T6.1/T6.2 в cellFormulaUtils

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 20:07:42 +04:00

708 lines
21 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 { FormsSheetApi } from '../../../api/form_sheet';
import { ProjectsApi } from '../../../api/projects';
import { useWebSocket } from '../hooks/useWebSocket';
import { clearLegacyFormulaStorage, formulaKey } from '../utils/cellFormulaUtils';
import { createFormulaStore } from '../utils/formulaStore';
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 FormulaStoreContext = createContext(null);
// AURORA-1896: формулы теперь хранятся на сервере — прежние ключи formula_* в localStorage
// удаляем один раз при загрузке модуля (идемпотентно, ошибки хранилища не критичны).
try {
clearLegacyFormulaStorage(window.localStorage);
} catch {
// localStorage недоступен (приватный режим и т.п.) — чистить нечего
}
export 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 useFormulaStore = () => useRequiredContext(FormulaStoreContext, 'useFormulaStore');
// AURORA-1896: формула ячейки (line_id + ключ колонки без `data.`) или undefined.
// Подписка по ключу — при событии перерисовывается только затронутая ячейка.
export const useCellFormula = (lineId, columnKey) => {
const store = useFormulaStore();
const key = formulaKey(lineId, columnKey);
const subscribe = useCallback((listener) => store.subscribe(key, listener), [key, store]);
const getSnapshot = useCallback(() => store.get(key), [key, store]);
return useSyncExternalStore(subscribe, getSnapshot, () => undefined);
};
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 formulaStoreRef = useRef(null);
if (formulaStoreRef.current === null) {
formulaStoreRef.current = createFormulaStore();
}
const formulaStore = formulaStoreRef.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':
// AURORA-1896: ответы с ошибкой отсекаются выше (data.error), сюда доходят только
// успешные события — и свои, и чужие. Фоновое событие без line_id/column карту не меняет.
formulaStoreRef.current.applyEvent(data.data);
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);
// AURORA-1896: смена листа/формы/года — карта прежнего листа не должна протекать.
// Ответы загрузок, начатых до сброса, стор отбрасывает по токену (reset/beginLoad).
// biome-ignore lint/correctness/useExhaustiveDependencies: идентификаторы листа — триггеры сброса карты
useEffect(() => {
formulaStore.reset();
return () => formulaStore.reset();
}, [formulaStore, formId, sheetName, year, isProject]);
// Карта формул грузится при открытии листа и при каждом (пере)подключении WS:
// за время обрыва другие пользователи могли изменить формулы.
// biome-ignore lint/correctness/useExhaustiveDependencies: isConnected — триггер перезагрузки при переподключении
useEffect(() => {
if (formId === undefined || formId === null || !sheetName) return;
const token = formulaStore.beginLoad();
const request = isProject ? ProjectsApi.getReportFormulas(formId, year, sheetName) : FormsSheetApi.getFormulas(formId, sheetName);
Promise.resolve(request)
.then((list) => {
formulaStore.finishLoad(token, Array.isArray(list) ? list : []);
})
.catch((error) => {
console.warn('Не удалось загрузить формулы листа:', error);
formulaStore.failLoad(token);
});
}, [formulaStore, formId, sheetName, year, isProject, isConnected]);
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, { formula } = {}) => {
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, formula });
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}>
<FormulaStoreContext.Provider value={formulaStore}>{children}</FormulaStoreContext.Provider>
</LockedCellsStoreContext.Provider>
</RealtimeVspOptionsContext.Provider>
</RealtimeConnectionContext.Provider>
</RealtimeActionsContext.Provider>
);
};