AURORA-1980: переименование программы и проекта

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-11-08 22:32:42 +04:00
parent 9218d2f7f5
commit 78227b46c0
8 changed files with 230 additions and 2 deletions

View File

@ -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 (
<ModalBackdrop isOpen onClick={handleBackdropClick}>
<ModalContainer height='max-content'>
<HeaderContainer>
<Stack direction='row' spacing={2} sx={{ alignItems: 'center' }}>
<ModalTitle>{TITLES[target.kind]}</ModalTitle>
</Stack>
<IconButton size='small' onClick={onClose}>
<Cancel />
</IconButton>
</HeaderContainer>
<Box>
<Divider sx={{ marginBottom: '0.75rem' }} />
<StyledTextField label='Название' value={name} onChange={(e) => setName(e.target.value)} />
<Stack direction='row' spacing={0.75} sx={{ justifyContent: 'flex-end', marginTop: '1.5rem', width: '100%' }}>
<DangerOutlinedButton onClick={onClose} disabled={isSaving}>
Отмена
</DangerOutlinedButton>
<PrimaryButton onClick={handleSave} disabled={!isFormValid || isSaving} loading={isSaving}>
Сохранить
</PrimaryButton>
</Stack>
</Box>
</ModalContainer>
</ModalBackdrop>
);
};
export const RenameNodeModal = memo(RenameNodeModalComponent);

View File

@ -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 }) => {
</div>
</div>
<ConfirmDeleteRowModal target={pendingDelete} onClose={closeConfirmDeleteRow} onConfirm={confirmDeleteRow} />
<RenameNodeModal target={pendingRename} isSaving={isRenameSaving} onClose={closeRenameNode} onSubmit={submitRenameNode} />
<SheetModals
ref={modalsRef}
formId={formId}

View File

@ -11,6 +11,7 @@ import {
AddLine,
AllStageColumns,
CurrentStageColumns,
EditSvg,
HiddenExpenseItems,
Minus,
Pin,
@ -88,6 +89,8 @@ const SettingPanel = ({
onAddRow,
onAddProgram,
onAddProject,
onRenameNode,
canRenameNode,
onDeleteRow,
formId,
sheetName,
@ -229,6 +232,14 @@ const SettingPanel = ({
<Project />
</IconButton>
</Tooltip>
<Tooltip title='Переименовать'>
<span>
<IconButton onClick={onRenameNode} disabled={!canRenameNode} variant='outlined' sx={controlSx}>
<EditSvg />
</IconButton>
</span>
</Tooltip>
</>
)}
</>

View File

@ -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,

View File

@ -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,

View File

@ -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, 'Новое');
});

View File

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

View File

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