diff --git a/web/src/components/RealtimeTable/Modals/CreateProgramProjectModal.jsx b/web/src/components/RealtimeTable/Modals/CreateProgramProjectModal.jsx index fee94e7..f5b6d77 100644 --- a/web/src/components/RealtimeTable/Modals/CreateProgramProjectModal.jsx +++ b/web/src/components/RealtimeTable/Modals/CreateProgramProjectModal.jsx @@ -30,7 +30,7 @@ const CreateProgramModalComponent = ({ isOpen, onClose, onCreate, title = '', is onClose(); }; - const isFormValid = name !== ''; + const isFormValid = name.trim() !== ''; if (!isOpen) return null; diff --git a/web/src/components/RealtimeTable/RealtimeTable.jsx b/web/src/components/RealtimeTable/RealtimeTable.jsx index d9ae991..deb3a23 100644 --- a/web/src/components/RealtimeTable/RealtimeTable.jsx +++ b/web/src/components/RealtimeTable/RealtimeTable.jsx @@ -28,7 +28,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 } from './utils/rowUtils'; +import { canAddRowForHeader, getDeleteTarget, getProjectAddTarget } from './utils/rowUtils'; import { clearLegacyFormulaKeys } from './utils/localStorageUtils'; import { formatHeaderPaths, getValidationRowKey } from './utils/validationUtils'; @@ -931,9 +931,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { const handleAddProjectRow = useCallback( (name) => { - const row = getSelectedRow(); - if (!row) return; - contextAddProject({ name, program_id: row.original.data.header.program_id }); + contextAddProject({ name: name.trim(), ...getProjectAddTarget(getSelectedRow()?.original) }); }, [contextAddProject, getSelectedRow], ); @@ -1015,17 +1013,8 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { toast.error('Добавление строк недоступно: нет доступных для редактирования столбцов'); return; } - const row = getSelectedRow(); - if (!row) { - toast.error('Выделите строку для вставки'); - return; - } - if (row.original.row_type !== 'GROUP') { - toast.error('Выделите строку программы'); - return; - } modalsRef.current?.open('project'); - }, [canModifyRows, getSelectedRow]); + }, [canModifyRows]); useEffect(() => { return () => { diff --git a/web/src/components/RealtimeTable/hooks/useRealtimeData.js b/web/src/components/RealtimeTable/hooks/useRealtimeData.js index 5d3f383..6d69fda 100644 --- a/web/src/components/RealtimeTable/hooks/useRealtimeData.js +++ b/web/src/components/RealtimeTable/hooks/useRealtimeData.js @@ -11,6 +11,7 @@ import { deleteRow, insertCell, insertProjectRow, + insertStandaloneProjectRow, updateCells, } from '../utils/cellUtils'; import { buildRowHierarchy, getParentId, isVspNewRow } from '../utils/rowUtils'; @@ -150,9 +151,14 @@ const useRealtimeData = (formId, sheetName, direction, formType, year) => { return; } setData((prevData) => { - const updateData = updateCells(prevData, updatedCells); - const newData = insertProjectRow(updateData, newRow, program_id); - return newData; + let base = structuredClone(prevData); + const rootRow = updates.find((row) => row[0] === 'ROOT'); + if (base[0]?.row_type !== 'ROOT' && rootRow) { + base = addRootRow(base, rootRow); + } + const updateData = updateCells(base, alignNodeRows(base, updatedCells)); + if (program_id != null) return insertProjectRow(updateData, newRow, program_id); + return insertStandaloneProjectRow(updateData, newRow); }); }); diff --git a/web/src/components/RealtimeTable/utils/cellUtils.js b/web/src/components/RealtimeTable/utils/cellUtils.js index 9e6d247..601072d 100644 --- a/web/src/components/RealtimeTable/utils/cellUtils.js +++ b/web/src/components/RealtimeTable/utils/cellUtils.js @@ -284,6 +284,30 @@ export function insertProjectRow(data, newRow, program_id) { return updatedRows; } +// AURORA-1984: самостоятельный проект (без программы) — под первый ROOT или на +// верхний уровень, перед первой строкой списка с большим sort_order (исторические +// INPUT идут после проектов). Номера строк с sort_order >= нового сдвигаются. +export function insertStandaloneProjectRow(data, newRow) { + const updatedRows = structuredClone(data); + const [row_type, depth, sort_order, dataRow] = newRow; + const newRowObject = { row_type, depth, sort_order, subRows: [], data: dataRow }; + + const shift = (rows) => { + for (const row of rows) { + if (row.sort_order >= sort_order) row.sort_order += 1; + if (Array.isArray(row.subRows)) shift(row.subRows); + } + }; + shift(updatedRows); + + const root = updatedRows.find((row) => row.row_type === 'ROOT'); + if (root && !Array.isArray(root.subRows)) root.subRows = []; + const target = root ? root.subRows : updatedRows; + const index = target.findIndex((row) => row.sort_order > sort_order); + target.splice(index === -1 ? target.length : index, 0, newRowObject); + return updatedRows; +} + export function deleteRow(data, lineId) { if (lineId == null) return data; return deleteRowWhere(data, (row) => getDataRowId(row.data) === lineId); diff --git a/web/src/components/RealtimeTable/utils/cellUtils.test.js b/web/src/components/RealtimeTable/utils/cellUtils.test.js index 7e31d11..1652177 100644 --- a/web/src/components/RealtimeTable/utils/cellUtils.test.js +++ b/web/src/components/RealtimeTable/utils/cellUtils.test.js @@ -1,6 +1,15 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { alignNodeRows, deleteProgramRow, deleteProjectRow, deleteRow, updateCells } from './cellUtils.js'; +import { + addRootRow, + alignNodeRows, + deleteProgramRow, + deleteProjectRow, + deleteRow, + insertCell, + insertStandaloneProjectRow, + updateCells, +} from './cellUtils.js'; // Эталон: прежняя реализация updateCells (до AURORA-1838). function oldUpdateCells(data, newRows) { @@ -385,3 +394,49 @@ test('AURORA-1978: alignNodeRows — ROOT по section_code, строки без ], ); }); + +const node = (rowType, sortOrder, header = {}, subRows = []) => ({ + row_type: rowType, + depth: { ROOT: 0, GROUP: 1, ITEM: 2, INPUT: 3 }[rowType], + sort_order: sortOrder, + subRows, + data: { header }, +}); +const newItem = (sortOrder) => ['ITEM', 2, sortOrder, { header: { project_id: 77, name: 'Н' } }]; + +test('AURORA-1984: самостоятельный проект — под ROOT, после соседей', () => { + const result = insertStandaloneProjectRow([node('ROOT', 1, {}, [node('GROUP', 2)])], newItem(3)); + assert.deepEqual( + result[0].subRows.map((r) => [r.row_type, r.sort_order]), + [ + ['GROUP', 2], + ['ITEM', 3], + ], + ); +}); + +test('AURORA-1984: самостоятельный проект — на верхний уровень без ROOT', () => { + const result = insertStandaloneProjectRow([node('GROUP', 1)], newItem(2)); + assert.deepEqual( + result.map((r) => r.row_type), + ['GROUP', 'ITEM'], + ); +}); + +test('AURORA-1984: самостоятельный проект — перед историческим расходом, номера сдвигаются', () => { + const result = insertStandaloneProjectRow([node('ROOT', 1, {}, [node('GROUP', 2), node('INPUT', 3)])], newItem(3)); + assert.deepEqual( + result[0].subRows.map((r) => [r.row_type, r.sort_order]), + [ + ['GROUP', 2], + ['ITEM', 3], + ['INPUT', 4], + ], + ); +}); + +test('AURORA-1984: insertCell после addRootRow — первый расход самостоятельного проекта', () => { + const withRoot = addRootRow([node('ITEM', 1, { project_id: 21 })], ['ROOT', 0, 1, { header: { expense_item_id: 900 } }]); + 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); +}); diff --git a/web/src/components/RealtimeTable/utils/rowUtils.js b/web/src/components/RealtimeTable/utils/rowUtils.js index 48d556f..4dad616 100644 --- a/web/src/components/RealtimeTable/utils/rowUtils.js +++ b/web/src/components/RealtimeTable/utils/rowUtils.js @@ -69,6 +69,15 @@ export function getDeleteTarget(rowData, { isForm4 = false } = {}) { return id == null ? null : { kind: 'row', id }; } +// AURORA-1984: цель добавления проекта по выделенной строке. Программа задана +// только если у GROUP/ITEM/INPUT header.program_id != null; иначе проект +// самостоятельный. rowData — row.original. +export function getProjectAddTarget(rowData) { + const programId = rowData?.data?.header?.program_id; + const typed = ['GROUP', 'ITEM', 'INPUT'].includes(rowData?.row_type); + return typed && programId != null ? { program_id: programId } : {}; +} + export function isVspRow(row) { if (row?.original?.data?.id) { return true; diff --git a/web/src/components/RealtimeTable/utils/rowUtils.test.js b/web/src/components/RealtimeTable/utils/rowUtils.test.js index a7b623f..281ddc8 100644 --- a/web/src/components/RealtimeTable/utils/rowUtils.test.js +++ b/web/src/components/RealtimeTable/utils/rowUtils.test.js @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { buildRowHierarchy, canAddRowForHeader, getDataRowId, getDeleteTarget } from './rowUtils.js'; +import { buildRowHierarchy, canAddRowForHeader, getDataRowId, getDeleteTarget, getProjectAddTarget } from './rowUtils.js'; test('row hierarchy uses row types when depth skips a level and keeps INPUT rows as siblings', () => { const rows = [ @@ -87,3 +87,13 @@ test('AURORA-1978: getDeleteTarget — строка бюджета и прочи assert.equal(getDeleteTarget(form4Row('ROOT', { section_code: 'S' }), { isForm4: true }), null); assert.equal(getDeleteTarget(null, { isForm4: true }), null); }); + +test('AURORA-1984: getProjectAddTarget — цель по выделенной строке', () => { + assert.deepEqual(getProjectAddTarget(undefined), {}); + assert.deepEqual(getProjectAddTarget(form4Row('ROOT', { section_code: 'CAP' })), {}); + assert.deepEqual(getProjectAddTarget(form4Row('GROUP', { program_id: 11 })), { program_id: 11 }); + assert.deepEqual(getProjectAddTarget(form4Row('ITEM', { program_id: 11, project_id: 21 })), { program_id: 11 }); + assert.deepEqual(getProjectAddTarget(form4Row('ITEM', { program_id: null, project_id: 21 })), {}); + assert.deepEqual(getProjectAddTarget(form4Row('ITEM', { project_id: 21 })), {}); + assert.deepEqual(getProjectAddTarget(form4Row('INPUT', { program_id: 11, project_id: 21 })), { program_id: 11 }); +});