diff --git a/web/src/components/RealtimeTable/Modals/RenameNodeModal.jsx b/web/src/components/RealtimeTable/Modals/RenameNodeModal.jsx
new file mode 100644
index 0000000..9eab314
--- /dev/null
+++ b/web/src/components/RealtimeTable/Modals/RenameNodeModal.jsx
@@ -0,0 +1,61 @@
+import { Box, Divider, IconButton, Stack } from '@mui/material';
+import { memo, useEffect, useState } from 'react';
+import { DangerOutlinedButton, PrimaryButton } from '../../common/Buttons/Buttons';
+import { HeaderContainer, ModalBackdrop, ModalContainer, ModalTitle } from '../../common/Modal/ModalStyled';
+import { StyledTextField } from '../../common/StyledTextField';
+import { Cancel } from '../../common/icons/icons';
+
+const TITLES = { program: 'Переименование программы', project: 'Переименование проекта' };
+
+// AURORA-1980: target — результат getRenameTarget; onSubmit получает обрезанное название.
+// Окно само не закрывается после отправки: закрытие/остаток при ошибке решает родитель.
+const RenameNodeModalComponent = ({ target, isSaving = false, onClose, onSubmit }) => {
+ const [name, setName] = useState('');
+
+ useEffect(() => {
+ setName(target?.name ?? '');
+ }, [target]);
+
+ if (!target) return null;
+
+ const isFormValid = name.trim() !== '';
+
+ const handleBackdropClick = (e) => {
+ if (e.target === e.currentTarget) onClose();
+ };
+
+ const handleSave = () => {
+ if (!isFormValid || isSaving) return;
+ onSubmit?.(name.trim());
+ };
+
+ return (
+
+
+
+
+ {TITLES[target.kind]}
+
+
+
+
+
+
+
+
+ setName(e.target.value)} />
+
+
+ Отмена
+
+
+ Сохранить
+
+
+
+
+
+ );
+};
+
+export const RenameNodeModal = memo(RenameNodeModalComponent);
diff --git a/web/src/components/RealtimeTable/RealtimeTable.jsx b/web/src/components/RealtimeTable/RealtimeTable.jsx
index deb3a23..76b1814 100644
--- a/web/src/components/RealtimeTable/RealtimeTable.jsx
+++ b/web/src/components/RealtimeTable/RealtimeTable.jsx
@@ -13,6 +13,7 @@ import { CircularProgress } from '@mui/material';
import { debounce } from '@mui/material';
import { toast } from 'react-toastify';
import { ConfirmDeleteRowModal } from './Modals/ConfirmDeleteRowModal';
+import { RenameNodeModal } from './Modals/RenameNodeModal';
import { SheetModals } from './Modals/SheetModals';
import { additionExpenseRowTable, additionVspRowTable } from './constants/addingRowConfig';
import { BASE_TABLE_CONFIG, TABLE_ROW_HEIGHT, TABLE_CONTAINER_SX, getTablePaperStyles, getTableRowHeightPx } from './constants/tableConfig';
@@ -28,7 +29,7 @@ import { useValidationRules } from './hooks/useValidationRules';
import { getColumnVirtualizerOptions, getOrderedVisibleColumns } from './utils/columnLayoutUtils';
import { isCellEditable } from './utils/cellEditability';
import { findNextEditableColumnId, findNextEditableRowId } from './utils/cellNavigation';
-import { canAddRowForHeader, getDeleteTarget, getProjectAddTarget } from './utils/rowUtils';
+import { canAddRowForHeader, getDeleteTarget, getProjectAddTarget, getRenameTarget, isRenameResponse } from './utils/rowUtils';
import { clearLegacyFormulaKeys } from './utils/localStorageUtils';
import { formatHeaderPaths, getValidationRowKey } from './utils/validationUtils';
@@ -139,6 +140,9 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
// AURORA-1978: { kind: 'row' | 'program' | 'project', id, name } — см. getDeleteTarget.
const [pendingDelete, setPendingDelete] = useState(null);
+ // AURORA-1980: { kind: 'program' | 'project', id, name } — см. getRenameTarget.
+ const [pendingRename, setPendingRename] = useState(null);
+ const [isRenameSaving, setIsRenameSaving] = useState(false);
const modalsRef = useRef(null);
const isFormNoCanAddRow = useMemo(() => FormsNoCanAddRow[formType]?.includes(sheetName) ?? false, [formType, sheetName]);
@@ -174,6 +178,8 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
deleteRow: contextDeleteRow,
deleteProgram: contextDeleteProgram,
deleteProject: contextDeleteProject,
+ renameNode: contextRenameNode,
+ subscribeToNodeRenameResult,
startEditing: contextStartEditing,
addProgram: contextAddProgram,
addProject: contextAddProject,
@@ -986,6 +992,37 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
setRowSelection({});
}, [contextDeleteProgram, contextDeleteProject, contextDeleteRow, pendingDelete]);
+ // AURORA-1980: ответ на своё переименование — успех закрывает окно, ошибка
+ // оставляет его с введённым текстом (тост показывает общий обработчик).
+ useEffect(
+ () =>
+ subscribeToNodeRenameResult((msg) => {
+ if (!isRenameResponse(msg, pendingRename)) return;
+ setIsRenameSaving(false);
+ if (!msg.error) setPendingRename(null);
+ }),
+ [pendingRename, subscribeToNodeRenameResult],
+ );
+
+ const canRenameNode = Boolean(getRenameTarget(getSelectedRow()?.original));
+
+ const handleRenameNode = useCallback(() => {
+ setPendingRename(getRenameTarget(getSelectedRow()?.original));
+ }, [getSelectedRow]);
+
+ const closeRenameNode = useCallback(() => {
+ setPendingRename(null);
+ setIsRenameSaving(false);
+ }, []);
+
+ const submitRenameNode = useCallback(
+ (name) => {
+ if (!pendingRename) return;
+ setIsRenameSaving(contextRenameNode(pendingRename.kind, pendingRename.id, name));
+ },
+ [contextRenameNode, pendingRename],
+ );
+
const addRow = useCallback(() => {
if (!canModifyRows) {
toast.error('Добавление строк недоступно: нет доступных для редактирования столбцов');
@@ -1086,6 +1123,8 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
onAddRow={addRow}
onAddProgram={addProgram}
onAddProject={addProject}
+ onRenameNode={handleRenameNode}
+ canRenameNode={canRenameNode}
onDeleteRow={handleDeleteRow}
formId={formId}
sheetName={sheetName}
@@ -1146,6 +1185,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
+
+
+
+
+
+
+
+
+
>
)}
>
diff --git a/web/src/components/RealtimeTable/contexts/RealtimeContext.js b/web/src/components/RealtimeTable/contexts/RealtimeContext.js
index 328915e..b45d02a 100644
--- a/web/src/components/RealtimeTable/contexts/RealtimeContext.js
+++ b/web/src/components/RealtimeTable/contexts/RealtimeContext.js
@@ -117,6 +117,11 @@ export const RealtimeProvider = ({ children, formId, sheetName, direction, year,
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;
}
@@ -176,6 +181,15 @@ export const RealtimeProvider = ({ children, formId, sheetName, direction, year,
}
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);
}
@@ -189,6 +203,8 @@ export const RealtimeProvider = ({ children, formId, sheetName, direction, year,
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);
@@ -450,6 +466,25 @@ export const RealtimeProvider = ({ children, formId, sheetName, direction, year,
[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 () => {
@@ -492,6 +527,20 @@ export const RealtimeProvider = ({ children, formId, sheetName, direction, year,
};
}, []);
+ 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 () => {
@@ -538,11 +587,14 @@ export const RealtimeProvider = ({ children, formId, sheetName, direction, year,
deleteRow,
deleteProgram,
deleteProject,
+ renameNode,
subscribeToRowAdds,
subscribeToRowDeletes,
subscribeToProgramAdds,
subscribeToProjectAdds,
subscribeToNodeDeletes,
+ subscribeToNodeUpdates,
+ subscribeToNodeRenameResult,
subscribeToErrors,
subscribeToAuthSuccess,
subscribeToCellEditStart,
@@ -560,6 +612,7 @@ export const RealtimeProvider = ({ children, formId, sheetName, direction, year,
deleteProject,
deleteRow,
endEditing,
+ renameNode,
lockedCellsStore,
retryLogin,
startEditing,
@@ -569,6 +622,8 @@ export const RealtimeProvider = ({ children, formId, sheetName, direction, year,
subscribeToCellUpdates,
subscribeToErrors,
subscribeToNodeDeletes,
+ subscribeToNodeRenameResult,
+ subscribeToNodeUpdates,
subscribeToProgramAdds,
subscribeToProjectAdds,
subscribeToRowAdds,
diff --git a/web/src/components/RealtimeTable/hooks/useRealtimeData.js b/web/src/components/RealtimeTable/hooks/useRealtimeData.js
index 6d69fda..e3a4ee4 100644
--- a/web/src/components/RealtimeTable/hooks/useRealtimeData.js
+++ b/web/src/components/RealtimeTable/hooks/useRealtimeData.js
@@ -33,6 +33,7 @@ const useRealtimeData = (formId, sheetName, direction, formType, year) => {
subscribeToProjectAdds,
subscribeToRowDeletes,
subscribeToNodeDeletes,
+ subscribeToNodeUpdates,
subscribeToErrors,
subscribeToCellEditStart,
subscribeToCellEditEnd,
@@ -213,6 +214,11 @@ const useRealtimeData = (formId, sheetName, direction, formType, year) => {
});
});
+ // AURORA-1980: переименование — имя в строке и пересчёт приходят всем.
+ const unsubscribeNodeUpdates = subscribeToNodeUpdates((msg) => {
+ setData((prevData) => updateCells(prevData, alignNodeRows(prevData, msg.result[1] ?? [])));
+ });
+
// Обработчик начала редактирования ячейки
const unsubscribeCellEditStart = subscribeToCellEditStart((data) => {
const dataCell = data.data;
@@ -253,6 +259,7 @@ const useRealtimeData = (formId, sheetName, direction, formType, year) => {
unsubscribeProjectAdds();
unsubscribeRowDeletes();
unsubscribeNodeDeletes();
+ unsubscribeNodeUpdates();
unsubscribeErrors();
unsubscribeCellEditStart();
unsubscribeCellEditEnd();
@@ -263,6 +270,7 @@ const useRealtimeData = (formId, sheetName, direction, formType, year) => {
subscribeToRowAdds,
subscribeToRowDeletes,
subscribeToNodeDeletes,
+ subscribeToNodeUpdates,
subscribeToErrors,
subscribeToCellEditStart,
subscribeToCellEditEnd,
diff --git a/web/src/components/RealtimeTable/utils/cellUtils.test.js b/web/src/components/RealtimeTable/utils/cellUtils.test.js
index 1652177..929ee9e 100644
--- a/web/src/components/RealtimeTable/utils/cellUtils.test.js
+++ b/web/src/components/RealtimeTable/utils/cellUtils.test.js
@@ -440,3 +440,10 @@ test('AURORA-1984: insertCell после addRootRow — первый расхо
const result = insertCell(withRoot, ['INPUT', 3, 3, { line_id: 5, header: { project_id: 21 } }], 21);
assert.equal(result[0].subRows[0].subRows[0].data.line_id, 5);
});
+
+test('AURORA-1980: ответ переименования обновляет название в строке', () => {
+ const local = [node('ROOT', 1, { section_code: 'CAP' }, [node('ITEM', 2, { project_id: 21, name: 'Старое' })])];
+ const rows = [['ITEM', 2, 5, { header: { project_id: 21, name: 'Новое' } }]];
+ const result = updateCells(local, alignNodeRows(local, rows));
+ assert.equal(result[0].subRows[0].data.header.name, 'Новое');
+});
diff --git a/web/src/components/RealtimeTable/utils/rowUtils.js b/web/src/components/RealtimeTable/utils/rowUtils.js
index 4dad616..d9ebe4c 100644
--- a/web/src/components/RealtimeTable/utils/rowUtils.js
+++ b/web/src/components/RealtimeTable/utils/rowUtils.js
@@ -103,3 +103,22 @@ export function getParentId(data) {
export function canAddRowForHeader(header) {
return header?.can_add_row !== false;
}
+
+// AURORA-1980: что переименовывает кнопка панели — программу (GROUP) или проект (ITEM) формы 4.
+export function getRenameTarget(rowData) {
+ const header = rowData?.data?.header;
+ if (rowData?.row_type === 'GROUP' && header?.program_id != null) {
+ return { kind: 'program', id: header.program_id, name: header.name ?? '' };
+ }
+ if (rowData?.row_type === 'ITEM' && header?.project_id != null) {
+ return { kind: 'project', id: header.project_id, name: header.name ?? '' };
+ }
+ return null;
+}
+
+// AURORA-1980: WS без идентификатора запроса — свой ответ на своё переименование
+// узнаём по is_self, событию и id узла (чужое переименование окно не закрывает).
+export function isRenameResponse(msg, target) {
+ if (!msg || !target || msg.is_self !== true) return false;
+ return msg.event === `${target.kind}_updated` && msg.data?.[`${target.kind}_id`] === target.id;
+}
diff --git a/web/src/components/RealtimeTable/utils/rowUtils.test.js b/web/src/components/RealtimeTable/utils/rowUtils.test.js
index 281ddc8..bbf6381 100644
--- a/web/src/components/RealtimeTable/utils/rowUtils.test.js
+++ b/web/src/components/RealtimeTable/utils/rowUtils.test.js
@@ -1,6 +1,14 @@
import assert from 'node:assert/strict';
import test from 'node:test';
-import { buildRowHierarchy, canAddRowForHeader, getDataRowId, getDeleteTarget, getProjectAddTarget } from './rowUtils.js';
+import {
+ buildRowHierarchy,
+ canAddRowForHeader,
+ getDataRowId,
+ getDeleteTarget,
+ getProjectAddTarget,
+ getRenameTarget,
+ isRenameResponse,
+} from './rowUtils.js';
test('row hierarchy uses row types when depth skips a level and keeps INPUT rows as siblings', () => {
const rows = [
@@ -97,3 +105,22 @@ test('AURORA-1984: getProjectAddTarget — цель по выделенной с
assert.deepEqual(getProjectAddTarget(form4Row('ITEM', { project_id: 21 })), {});
assert.deepEqual(getProjectAddTarget(form4Row('INPUT', { program_id: 11, project_id: 21 })), { program_id: 11 });
});
+
+test('AURORA-1980: getRenameTarget — программа, проект, остальное', () => {
+ assert.deepEqual(getRenameTarget(form4Row('GROUP', { program_id: 11, name: 'А' })), { kind: 'program', id: 11, name: 'А' });
+ assert.deepEqual(getRenameTarget(form4Row('ITEM', { project_id: 21, name: 'Б' })), { kind: 'project', id: 21, name: 'Б' });
+ assert.equal(getRenameTarget(form4Row('ROOT', { section_code: 'CAP' })), null);
+ assert.equal(getRenameTarget(form4Row('INPUT', { project_id: 21 })), null);
+ assert.equal(getRenameTarget(undefined), null);
+});
+
+test('AURORA-1980: isRenameResponse — только свой ответ на свой узел', () => {
+ const target = { kind: 'project', id: 21, name: 'Б' };
+ const msg = { is_self: true, event: 'project_updated', data: { project_id: 21 } };
+ assert.equal(isRenameResponse(msg, target), true);
+ assert.equal(isRenameResponse({ ...msg, error: 'нет прав' }, target), true);
+ assert.equal(isRenameResponse({ ...msg, is_self: false }, target), false);
+ assert.equal(isRenameResponse({ ...msg, data: { project_id: 22 } }, target), false);
+ assert.equal(isRenameResponse({ ...msg, event: 'program_updated', data: { program_id: 21 } }, target), false);
+ assert.equal(isRenameResponse(msg, null), false);
+});