AURORA-1984: проекты ПД без программы (+1979, 1980, 1989) #306
61
web/src/components/RealtimeTable/Modals/RenameNodeModal.jsx
Normal file
61
web/src/components/RealtimeTable/Modals/RenameNodeModal.jsx
Normal 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);
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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, 'Новое');
|
||||
});
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user