feat: port stroy-bot-objects as objects page

Project object tree with add/edit/delete, drag & drop re-parenting,
context menu and Excel export, 1:1 with the legacy web part. The tree
widgets live in pages/_tree so the works page can reuse them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Тимур Городецкий
2026-10-16 09:48:04 +05:00
parent 99a7631b65
commit e12fce6032
30 changed files with 2399 additions and 2 deletions
@@ -0,0 +1,71 @@
import * as React from 'react';
import { confirmModal } from '@tnsu/ui-kit';
import { IObjectsRepository } from './objectsRepository';
import { getChangedParentId, getDeleteConfirmMessage, getErrorText, resolveMove } from './objectsRules';
import { DropPosition, IObjectModalTarget, ObjectNode, ObjectTree } from './objectsTypes';
import { OBJECTS_TEXTS } from './texts';
interface IActionsDeps {
repository: IObjectsRepository;
projectTitle: string | undefined;
tree: ObjectTree;
reload: () => void;
}
export interface IObjectActions {
/** Ошибка переноса или удаления (раньше — alert). */
actionError: string | undefined;
/** Создание / правка из модалки. Ошибку бросает дальше — модалка покажет её у себя. */
saveObject: (target: IObjectModalTarget, title: string, parentValue: string) => Promise<void>;
moveObject: (dragId: number, targetId: number, position: DropPosition) => Promise<void>;
deleteObject: (node: ObjectNode) => Promise<void>;
}
/** Пишущие действия над деревом объектов. После каждого успешного действия дерево перечитывается. */
export function useObjectActions({ repository, projectTitle, tree, reload }: IActionsDeps): IObjectActions {
const [actionError, setActionError] = React.useState<string | undefined>(undefined);
const saveObject = async (target: IObjectModalTarget, title: string, parentValue: string): Promise<void> => {
const { mode, node } = target;
// Как в старом коде: объект привязывается к проекту по названию (текстовое поле project).
const project = projectTitle ?? '';
if (mode === 'addRoot') {
await repository.createObject(project, title, undefined);
} else if (mode === 'add' && node) {
await repository.createObject(project, title, node.id);
} else if (node) {
await repository.updateObject(node.id, title, getChangedParentId(node, parentValue));
}
reload();
};
const moveObject = async (dragId: number, targetId: number, position: DropPosition): Promise<void> => {
const move = resolveMove(tree, dragId, targetId, position);
if (!move) return;
setActionError(undefined);
try {
await repository.moveObject(dragId, move.parentId);
reload();
} catch (error) {
setActionError(OBJECTS_TEXTS.moveError(getErrorText(error)));
}
};
const deleteObject = async (node: ObjectNode): Promise<void> => {
const isConfirmed = await confirmModal({
message: getDeleteConfirmMessage(node),
confirmText: OBJECTS_TEXTS.menu.delete,
danger: true
});
if (!isConfirmed) return;
setActionError(undefined);
try {
await repository.deleteObject(node.id);
reload();
} catch (error) {
setActionError(OBJECTS_TEXTS.deleteError(getErrorText(error)));
}
};
return { actionError, saveObject, moveObject, deleteObject };
}