AURORA-1984: добавление проекта в разделе на фронте

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-11-08 22:29:42 +04:00
parent 69c4a8f9c9
commit 374ac4bd9e
7 changed files with 113 additions and 20 deletions

View File

@ -30,7 +30,7 @@ const CreateProgramModalComponent = ({ isOpen, onClose, onCreate, title = '', is
onClose(); onClose();
}; };
const isFormValid = name !== ''; const isFormValid = name.trim() !== '';
if (!isOpen) return null; if (!isOpen) return null;

View File

@ -28,7 +28,7 @@ import { useValidationRules } from './hooks/useValidationRules';
import { getColumnVirtualizerOptions, getOrderedVisibleColumns } from './utils/columnLayoutUtils'; import { getColumnVirtualizerOptions, getOrderedVisibleColumns } from './utils/columnLayoutUtils';
import { isCellEditable } from './utils/cellEditability'; import { isCellEditable } from './utils/cellEditability';
import { findNextEditableColumnId, findNextEditableRowId } from './utils/cellNavigation'; 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 { clearLegacyFormulaKeys } from './utils/localStorageUtils';
import { formatHeaderPaths, getValidationRowKey } from './utils/validationUtils'; import { formatHeaderPaths, getValidationRowKey } from './utils/validationUtils';
@ -931,9 +931,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
const handleAddProjectRow = useCallback( const handleAddProjectRow = useCallback(
(name) => { (name) => {
const row = getSelectedRow(); contextAddProject({ name: name.trim(), ...getProjectAddTarget(getSelectedRow()?.original) });
if (!row) return;
contextAddProject({ name, program_id: row.original.data.header.program_id });
}, },
[contextAddProject, getSelectedRow], [contextAddProject, getSelectedRow],
); );
@ -1015,17 +1013,8 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
toast.error('Добавление строк недоступно: нет доступных для редактирования столбцов'); toast.error('Добавление строк недоступно: нет доступных для редактирования столбцов');
return; return;
} }
const row = getSelectedRow();
if (!row) {
toast.error('Выделите строку для вставки');
return;
}
if (row.original.row_type !== 'GROUP') {
toast.error('Выделите строку программы');
return;
}
modalsRef.current?.open('project'); modalsRef.current?.open('project');
}, [canModifyRows, getSelectedRow]); }, [canModifyRows]);
useEffect(() => { useEffect(() => {
return () => { return () => {

View File

@ -11,6 +11,7 @@ import {
deleteRow, deleteRow,
insertCell, insertCell,
insertProjectRow, insertProjectRow,
insertStandaloneProjectRow,
updateCells, updateCells,
} from '../utils/cellUtils'; } from '../utils/cellUtils';
import { buildRowHierarchy, getParentId, isVspNewRow } from '../utils/rowUtils'; import { buildRowHierarchy, getParentId, isVspNewRow } from '../utils/rowUtils';
@ -150,9 +151,14 @@ const useRealtimeData = (formId, sheetName, direction, formType, year) => {
return; return;
} }
setData((prevData) => { setData((prevData) => {
const updateData = updateCells(prevData, updatedCells); let base = structuredClone(prevData);
const newData = insertProjectRow(updateData, newRow, program_id); const rootRow = updates.find((row) => row[0] === 'ROOT');
return newData; 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);
}); });
}); });

View File

@ -284,6 +284,30 @@ export function insertProjectRow(data, newRow, program_id) {
return updatedRows; 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) { export function deleteRow(data, lineId) {
if (lineId == null) return data; if (lineId == null) return data;
return deleteRowWhere(data, (row) => getDataRowId(row.data) === lineId); return deleteRowWhere(data, (row) => getDataRowId(row.data) === lineId);

View File

@ -1,6 +1,15 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { test } from 'node:test'; 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). // Эталон: прежняя реализация updateCells (до AURORA-1838).
function oldUpdateCells(data, newRows) { 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);
});

View File

@ -69,6 +69,15 @@ export function getDeleteTarget(rowData, { isForm4 = false } = {}) {
return id == null ? null : { kind: 'row', id }; 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) { export function isVspRow(row) {
if (row?.original?.data?.id) { if (row?.original?.data?.id) {
return true; return true;

View File

@ -1,6 +1,6 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; 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', () => { test('row hierarchy uses row types when depth skips a level and keeps INPUT rows as siblings', () => {
const rows = [ const rows = [
@ -87,3 +87,13 @@ test('AURORA-1978: getDeleteTarget — строка бюджета и прочи
assert.equal(getDeleteTarget(form4Row('ROOT', { section_code: 'S' }), { isForm4: true }), null); assert.equal(getDeleteTarget(form4Row('ROOT', { section_code: 'S' }), { isForm4: true }), null);
assert.equal(getDeleteTarget(null, { 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 });
});