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