drag.onZoneDragOver({ targetId, position }, event)}
+ onDragLeave={drag.onZoneDragLeave}
+ />
+ );
+};
diff --git a/src/webparts/stroyBot/pages/objects/ObjectModal.tsx b/src/webparts/stroyBot/pages/objects/ObjectModal.tsx
new file mode 100644
index 0000000..43ff575
--- /dev/null
+++ b/src/webparts/stroyBot/pages/objects/ObjectModal.tsx
@@ -0,0 +1,110 @@
+import * as React from 'react';
+import { Alert, Button, Dialog, FormField, Select, Spinner, TextInput } from '@tnsu/ui-kit';
+import { FormInput } from '../_tree/FormInput';
+import {
+ getErrorText,
+ getModalTitle,
+ getParentDisplay,
+ getParentOptions,
+ getSubmitLabel,
+ isParentSelectable
+} from './objectsRules';
+import { IObjectModalTarget, ObjectTree } from './objectsTypes';
+import { OBJECTS_TEXTS } from './texts';
+import styles from './ObjectsPage.module.scss';
+
+const TEXTS = OBJECTS_TEXTS.modal;
+const TITLE_INPUT_ID = 'stroybot-object-title';
+
+interface Props {
+ target: IObjectModalTarget;
+ tree: ObjectTree;
+ projectTitle: string;
+ onSubmit: (title: string, parentValue: string) => Promise
;
+ onClose: () => void;
+}
+
+/** Модалка добавления / правки объекта. Монтируется заново при каждом открытии. */
+export const ObjectModal: React.FC = ({ target, tree, projectTitle, onSubmit, onClose }) => {
+ const { mode, node } = target;
+ const [title, setTitle] = React.useState(mode === 'edit' && node ? node.item.title : '');
+ const [parentValue, setParentValue] = React.useState(node?.item.parentId ? String(node.item.parentId) : '');
+ const [isTitleMissing, setIsTitleMissing] = React.useState(false);
+ const [isSaving, setIsSaving] = React.useState(false);
+ const [saveError, setSaveError] = React.useState(undefined);
+ const titleRef = React.useRef(null);
+
+ const onTitleChange = (value: string): void => {
+ setTitle(value);
+ setIsTitleMissing(false);
+ };
+
+ const submit = async (): Promise => {
+ const trimmed = title.trim();
+ if (!trimmed) {
+ setIsTitleMissing(true);
+ titleRef.current?.focus();
+ return;
+ }
+ setIsSaving(true);
+ setSaveError(undefined);
+ try {
+ await onSubmit(trimmed, parentValue);
+ } catch (error) {
+ setSaveError(OBJECTS_TEXTS.saveError(getErrorText(error)));
+ setIsSaving(false);
+ }
+ };
+
+ const onSave = (): void => {
+ submit().catch(() => undefined);
+ };
+
+ const footer = (
+ <>
+
+
+ >
+ );
+
+ return (
+
+ );
+};
diff --git a/src/webparts/stroyBot/pages/objects/ObjectTreeNode.tsx b/src/webparts/stroyBot/pages/objects/ObjectTreeNode.tsx
new file mode 100644
index 0000000..8c76af6
--- /dev/null
+++ b/src/webparts/stroyBot/pages/objects/ObjectTreeNode.tsx
@@ -0,0 +1,114 @@
+import * as React from 'react';
+import { Icon } from '@tnsu/ui-kit';
+import { IMenuAnchor } from '../_tree/ContextMenu';
+import { ObjectNode } from './objectsTypes';
+import { IObjectTreeView } from './useObjectTreeView';
+import { IObjectDrag } from './useObjectDrag';
+import { DropZone } from './DropZone';
+import { OBJECTS_TEXTS } from './texts';
+import styles from './ObjectsPage.module.scss';
+
+interface Props {
+ node: ObjectNode;
+ canEdit: boolean;
+ view: IObjectTreeView;
+ drag: IObjectDrag;
+ onOpenMenu: (node: ObjectNode, anchor: IMenuAnchor) => void;
+}
+
+/** Отступ строки: базовый + шаг на уровень — как в stroy-bot-objects. */
+const ROW_BASE_INDENT_PX = 12;
+const ROW_LEVEL_INDENT_PX = 22;
+
+function rowClassName(hasChildren: boolean, isSelected: boolean, isDragging: boolean): string {
+ return [
+ styles.nodeRow,
+ hasChildren ? styles.nodeRowParent : '',
+ isSelected ? styles.nodeRowSelected : '',
+ isDragging ? styles.nodeRowDragging : ''
+ ]
+ .filter(Boolean)
+ .join(' ');
+}
+
+/** Узел дерева объектов с зонами сброса и (если раскрыт) дочерними узлами. */
+export const ObjectTreeNode: React.FC = ({ node, canEdit, view, drag, onOpenMenu }) => {
+ const hasChildren = node.children.length > 0;
+ const isExpanded = view.expandedIds.has(node.id);
+
+ const onToggleClick = (event: React.MouseEvent): void => {
+ event.stopPropagation();
+ if (hasChildren) view.toggle(node.id);
+ };
+
+ // stopPropagation: иначе этот же клик дойдёт до document и сразу закроет открытое меню.
+ const onMenuClick = (event: React.MouseEvent): void => {
+ event.stopPropagation();
+ const rect = event.currentTarget.getBoundingClientRect();
+ view.select(node.id);
+ onOpenMenu(node, { x: rect.right, y: rect.bottom });
+ };
+
+ const onContextMenu = (event: React.MouseEvent): void => {
+ if (!canEdit) return;
+ event.preventDefault();
+ view.select(node.id);
+ onOpenMenu(node, { x: event.clientX, y: event.clientY });
+ };
+
+ return (
+
+
+
drag.onDragStart(node.id, event)}
+ onDragEnd={drag.onDragEnd}
+ onClick={() => view.clickRow(node)}
+ onContextMenu={onContextMenu}
+ >
+
+ {hasChildren ? (
+
+
+
+ ) : (
+
+ )}
+
+
+
+
+ {node.item.title}
+ {hasChildren && {node.children.length}}
+ {canEdit && (
+
+ )}
+
+
+ {hasChildren && isExpanded && (
+
+ {node.children.map((child) => (
+
+ ))}
+
+ )}
+
+
+ );
+};
diff --git a/src/webparts/stroyBot/pages/objects/ObjectsHeader.tsx b/src/webparts/stroyBot/pages/objects/ObjectsHeader.tsx
new file mode 100644
index 0000000..54751d1
--- /dev/null
+++ b/src/webparts/stroyBot/pages/objects/ObjectsHeader.tsx
@@ -0,0 +1,41 @@
+import * as React from 'react';
+import { Button, Icon, Spinner } from '@tnsu/ui-kit';
+import { TreePageHeader } from '../_tree/TreePageHeader';
+import { OBJECTS_TEXTS } from './texts';
+
+const TEXTS = OBJECTS_TEXTS.header;
+
+interface Props {
+ title: string;
+ canEdit: boolean;
+ isExporting: boolean;
+ onAddRoot: () => void;
+ onExport: () => void;
+}
+
+/** Шапка «Объекты проекта»: название проекта, «Добавить объект» (только с правом edit0) и «Экспорт». */
+export const ObjectsHeader: React.FC = ({ title, canEdit, isExporting, onAddRoot, onExport }) => (
+
+ {canEdit && (
+
+ )}
+
+ >
+ }
+ />
+);
diff --git a/src/webparts/stroyBot/pages/objects/ObjectsPage.module.scss b/src/webparts/stroyBot/pages/objects/ObjectsPage.module.scss
new file mode 100644
index 0000000..9ed51a8
--- /dev/null
+++ b/src/webparts/stroyBot/pages/objects/ObjectsPage.module.scss
@@ -0,0 +1,222 @@
+@import '../../../../styles/design-tokens';
+
+// Размеры дерева — как в stroy-bot-objects.
+$tree-max-height: 600px; // длинное дерево прокручивается внутри карточки
+$toggle-size: 18px;
+$leaf-dot-size: 5px;
+$badge-size: 22px;
+$menu-button-size: 28px;
+
+.page {
+ display: flex;
+ flex-direction: column;
+ gap: $sp-3;
+ width: 100%;
+}
+
+.card {
+ background: $white;
+ border: 1px solid $gray-200;
+ border-radius: $r-lg;
+ box-shadow: $sh-1;
+ overflow: hidden;
+}
+
+.loader {
+ padding: 48px $sp-5;
+}
+
+.errorBlock {
+ padding: $sp-4;
+}
+
+// ── Панель над деревом ──────────────────────────────────────────────────────
+.toolbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: $sp-3;
+ flex-wrap: wrap;
+ padding: 10px $sp-4;
+ background: $gray-50;
+ border-bottom: 1px solid $gray-200;
+}
+
+.totalCount {
+ font-size: $fs-overline;
+ font-weight: 600;
+ letter-spacing: 1.2px;
+ text-transform: uppercase;
+ color: $gray-400;
+}
+
+.toolbarActions {
+ display: flex;
+ gap: 6px;
+}
+
+// ── Дерево ──────────────────────────────────────────────────────────────────
+.tree {
+ max-height: $tree-max-height;
+ overflow-y: auto;
+ padding: $sp-2 0;
+}
+
+.nodeRow {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ margin: 1px $sp-2;
+ padding-top: 6px;
+ padding-bottom: 6px;
+ padding-right: $sp-4;
+ border-radius: $r-sm;
+ cursor: default;
+ transition: background 0.18s;
+
+ &:hover {
+ background: $gold-50;
+ }
+}
+
+.nodeRowParent {
+ cursor: pointer;
+}
+
+.nodeRowSelected {
+ background: $gold-50;
+
+ .nodeTitle {
+ font-weight: 600;
+ }
+}
+
+.nodeRowDragging {
+ opacity: 0.45;
+}
+
+.nodeToggle {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ width: $toggle-size;
+ height: $toggle-size;
+}
+
+.chevron,
+.chevronExpanded {
+ display: inline-flex;
+ color: $gray-400;
+}
+
+.chevronExpanded {
+ color: $navy-700;
+}
+
+.leafDot {
+ display: inline-block;
+ width: $leaf-dot-size;
+ height: $leaf-dot-size;
+ border-radius: $r-full;
+ background: $gray-300;
+}
+
+.iconFolder,
+.iconFile {
+ display: inline-flex;
+ flex-shrink: 0;
+}
+
+.iconFolder {
+ color: $gold-600;
+}
+
+.iconFile {
+ color: $gray-400;
+}
+
+.nodeTitle {
+ flex: 1;
+ min-width: 0;
+ font-size: $fs-body;
+ font-weight: 500;
+ line-height: 1.4;
+ color: $navy-900;
+ word-break: break-word;
+}
+
+.nodeBadge {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ min-width: $badge-size;
+ height: $badge-size;
+ padding: 0 6px;
+ border-radius: $r-full;
+ background: $navy-900;
+ font-size: $fs-caption;
+ font-weight: 600;
+ color: $gold-300;
+}
+
+.nodeMenuButton {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ width: $menu-button-size;
+ height: $menu-button-size;
+ margin-left: auto;
+ border: 1px solid transparent;
+ border-radius: $r-sm;
+ background: transparent;
+ color: $gray-400;
+ cursor: pointer;
+
+ &:hover {
+ background: $navy-50;
+ border-color: $gray-200;
+ color: $navy-800;
+ }
+}
+
+// ── Зоны сброса при перетаскивании ──────────────────────────────────────────
+// Высоты 1:1 со старым web part: зона «внутрь» нулевой высоты (см. открытые вопросы в чек-листе).
+.dropZone {
+ margin: 0 $sp-2;
+ border-radius: 1px;
+ transition: background 0.12s, height 0.12s;
+}
+
+.dropZoneEdge {
+ height: 2px;
+
+ &.dropZoneActive {
+ height: 3px;
+ background: $gold-400;
+ }
+}
+
+.dropZoneInside {
+ height: 0;
+
+ &.dropZoneActive {
+ height: 2px;
+ background: rgba($gold-400, 0.3);
+ }
+}
+
+// ── Модалка ─────────────────────────────────────────────────────────────────
+.modalBody {
+ display: flex;
+ flex-direction: column;
+ gap: $sp-4;
+}
+
+.fieldError {
+ font-size: $fs-label;
+ font-weight: 500;
+ color: $red-500;
+}
diff --git a/src/webparts/stroyBot/pages/objects/ObjectsPage.tsx b/src/webparts/stroyBot/pages/objects/ObjectsPage.tsx
new file mode 100644
index 0000000..96e407e
--- /dev/null
+++ b/src/webparts/stroyBot/pages/objects/ObjectsPage.tsx
@@ -0,0 +1,12 @@
+import * as React from 'react';
+import { useRouter } from '../../app/routing/RouterContext';
+import { ObjectsPageContent } from './ObjectsPageContent';
+
+/**
+ * Раздел «Объекты» (бывший stroy-bot-objects): дерево объектов проекта из ?projectId=.
+ * Смена проекта — новое состояние страницы (раскрытые узлы, выделение, меню).
+ */
+export const ObjectsPage: React.FC = () => {
+ const { route } = useRouter();
+ return ;
+};
diff --git a/src/webparts/stroyBot/pages/objects/ObjectsPageContent.tsx b/src/webparts/stroyBot/pages/objects/ObjectsPageContent.tsx
new file mode 100644
index 0000000..f91da5a
--- /dev/null
+++ b/src/webparts/stroyBot/pages/objects/ObjectsPageContent.tsx
@@ -0,0 +1,135 @@
+import * as React from 'react';
+import { Alert, Spinner } from '@tnsu/ui-kit';
+import { useAppServices } from '../../app/AppServicesContext';
+import { useRouter } from '../../app/routing/RouterContext';
+import { ContextMenu, IMenuAnchor } from '../_tree/ContextMenu';
+import { createObjectsRepository } from './objectsRepository';
+import { IObjectModalTarget, ObjectNode } from './objectsTypes';
+import { buildObjectMenuItems, ObjectMenuAction } from './objectMenu';
+import { useObjectsData } from './useObjectsData';
+import { useObjectActions } from './useObjectActions';
+import { useObjectsExport } from './useObjectsExport';
+import { useObjectTreeView } from './useObjectTreeView';
+import { useObjectDrag } from './useObjectDrag';
+import { ObjectsHeader } from './ObjectsHeader';
+import { ObjectsTree } from './ObjectsTree';
+import { ObjectModal } from './ObjectModal';
+import { OBJECTS_TEXTS } from './texts';
+import styles from './ObjectsPage.module.scss';
+
+interface Props {
+ projectId: string | undefined;
+}
+
+interface IOpenMenu {
+ node: ObjectNode;
+ anchor: IMenuAnchor;
+}
+
+/** Страница «Объекты»: шапка, дерево, меню узла, модалка. Данные и действия — в хуках. */
+export const ObjectsPageContent: React.FC = ({ projectId }) => {
+ const services = useAppServices();
+ const { navigate } = useRouter();
+ const repository = React.useMemo(
+ () =>
+ createObjectsRepository({
+ sharePoint: services.sharePoint,
+ tnsuApi: services.tnsuApi,
+ siteUrl: services.settings.siteUrl,
+ apiBaseUrl: services.settings.apiBaseUrl
+ }),
+ [services]
+ );
+
+ const data = useObjectsData(repository, projectId);
+ const actions = useObjectActions({
+ repository,
+ projectTitle: data.projectTitle,
+ tree: data.tree,
+ reload: data.reload
+ });
+ const exporter = useObjectsExport(repository, projectId);
+ const view = useObjectTreeView(data.tree);
+ const drag = useObjectDrag(data.tree, (dragId, targetId, position) => {
+ actions.moveObject(dragId, targetId, position).catch(() => undefined);
+ });
+ const [menu, setMenu] = React.useState(undefined);
+ const [modalTarget, setModalTarget] = React.useState(undefined);
+
+ const openAddRoot = (): void => setModalTarget({ mode: 'addRoot', node: undefined });
+
+ const onMenuSelect = (node: ObjectNode, action: ObjectMenuAction): void => {
+ setMenu(undefined);
+ if (action === 'add' || action === 'edit') {
+ setModalTarget({ mode: action, node });
+ } else if (action === 'works') {
+ navigate({ page: 'works', projectId, objectId: String(node.id) });
+ } else {
+ actions.deleteObject(node).catch(() => undefined);
+ }
+ };
+
+ const onModalSubmit = async (title: string, parentValue: string): Promise => {
+ if (!modalTarget) return;
+ await actions.saveObject(modalTarget, title, parentValue);
+ setModalTarget(undefined);
+ };
+
+ const headerTitle =
+ data.status === 'error' ? OBJECTS_TEXTS.header.titleError : data.projectTitle ?? OBJECTS_TEXTS.header.titleLoading;
+
+ return (
+
+
{
+ exporter.exportExcel().catch(() => undefined);
+ }}
+ />
+
+ {exporter.exportError && {exporter.exportError}}
+ {actions.actionError && {actions.actionError}}
+
+
+ {data.status === 'loading' &&
}
+ {data.status === 'error' && (
+
+ )}
+ {data.status === 'ready' && (
+
setMenu({ node, anchor })}
+ onAddRoot={openAddRoot}
+ />
+ )}
+
+
+ {menu && (
+ onMenuSelect(menu.node, key)}
+ onClose={() => setMenu(undefined)}
+ />
+ )}
+
+ {modalTarget && (
+ setModalTarget(undefined)}
+ />
+ )}
+
+ );
+};
diff --git a/src/webparts/stroyBot/pages/objects/ObjectsTree.tsx b/src/webparts/stroyBot/pages/objects/ObjectsTree.tsx
new file mode 100644
index 0000000..0d1821f
--- /dev/null
+++ b/src/webparts/stroyBot/pages/objects/ObjectsTree.tsx
@@ -0,0 +1,64 @@
+import * as React from 'react';
+import { Button } from '@tnsu/ui-kit';
+import { EmptyState } from '../../shared/components/EmptyState';
+import { IMenuAnchor } from '../_tree/ContextMenu';
+import { countNodes } from '../_tree/treeRules';
+import { ObjectNode, ObjectTree } from './objectsTypes';
+import { IObjectTreeView } from './useObjectTreeView';
+import { IObjectDrag } from './useObjectDrag';
+import { ObjectTreeNode } from './ObjectTreeNode';
+import { OBJECTS_TEXTS } from './texts';
+import styles from './ObjectsPage.module.scss';
+
+interface Props {
+ tree: ObjectTree;
+ canEdit: boolean;
+ view: IObjectTreeView;
+ drag: IObjectDrag;
+ onOpenMenu: (node: ObjectNode, anchor: IMenuAnchor) => void;
+ onAddRoot: () => void;
+}
+
+/** Панель «N объектов / Развернуть / Свернуть» и дерево объектов (или «Объекты не найдены»). */
+export const ObjectsTree: React.FC = ({ tree, canEdit, view, drag, onOpenMenu, onAddRoot }) => (
+ <>
+
+
{OBJECTS_TEXTS.toolbar.total(countNodes(tree.roots))}
+
+
+
+
+
+
+ {tree.roots.length === 0 && (
+
+ {OBJECTS_TEXTS.emptyAdd}
+
+ )
+ }
+ />
+ )}
+
+
+ {tree.roots.map((node) => (
+
+ ))}
+
+ >
+);
diff --git a/src/webparts/stroyBot/pages/objects/objectMenu.ts b/src/webparts/stroyBot/pages/objects/objectMenu.ts
new file mode 100644
index 0000000..1cc7648
--- /dev/null
+++ b/src/webparts/stroyBot/pages/objects/objectMenu.ts
@@ -0,0 +1,22 @@
+import { IContextMenuItem } from '../_tree/ContextMenu';
+import { canAddChild } from './objectsRules';
+import { ObjectNode } from './objectsTypes';
+import { OBJECTS_TEXTS } from './texts';
+
+export type ObjectMenuAction = 'add' | 'edit' | 'works' | 'delete';
+
+const TEXTS = OBJECTS_TEXTS.menu;
+
+/** Пункты меню узла. «Добавить объект» скрыт у Вида работ (уровень 2) — глубже дерево не растёт. */
+export function buildObjectMenuItems(node: ObjectNode): Array> {
+ const items: Array> = [];
+ if (canAddChild(node)) {
+ items.push({ key: 'add', label: TEXTS.add, icon: 'plus' });
+ }
+ items.push(
+ { key: 'edit', label: TEXTS.edit, icon: 'edit' },
+ { key: 'works', label: TEXTS.works, icon: 'clipboard' },
+ { key: 'delete', label: TEXTS.delete, icon: 'trash', danger: true, dividerBefore: true }
+ );
+ return items;
+}
diff --git a/src/webparts/stroyBot/pages/objects/objectsRepository.test.ts b/src/webparts/stroyBot/pages/objects/objectsRepository.test.ts
new file mode 100644
index 0000000..ee97ee9
--- /dev/null
+++ b/src/webparts/stroyBot/pages/objects/objectsRepository.test.ts
@@ -0,0 +1,227 @@
+import {
+ accessUrl,
+ createObjectsRepository,
+ IObjectsRepository,
+ objectItemUrl,
+ objectsByProjectUrl,
+ objectsEntityTypeUrl,
+ objectsListItemsUrl,
+ projectObjectsExportUrl,
+ projectTitleUrl
+} from './objectsRepository';
+import { createFakeHttpClient, IFakeHttpClient, IFakeRoute } from '../../shared/testing/fakeHttpClient';
+
+const SITE = 'https://tnsukz.sharepoint.com/sites/stroy';
+const API = 'https://api.tnsu.kz';
+const OBJECTS = encodeURIComponent('Объекты');
+const PROJECTS = encodeURIComponent('Проекты');
+const ENTITY_TYPE = 'SP.Data.ObjectsListItem';
+
+const VERBOSE_HEADERS = {
+ Accept: 'application/json;odata=verbose',
+ 'Content-Type': 'application/json;odata=verbose',
+ 'odata-version': ''
+};
+const MERGE_HEADERS = { ...VERBOSE_HEADERS, 'IF-MATCH': '*', 'X-HTTP-Method': 'MERGE' };
+
+const ENTITY_TYPE_ROUTE: IFakeRoute = {
+ match: 'ListItemEntityTypeFullName',
+ response: { body: { ListItemEntityTypeFullName: ENTITY_TYPE } }
+};
+
+function setup(
+ spRoutes: IFakeRoute[],
+ apiRoutes: IFakeRoute[] = []
+): { sharePoint: IFakeHttpClient; tnsuApi: IFakeHttpClient; repository: IObjectsRepository } {
+ const sharePoint = createFakeHttpClient(spRoutes);
+ const tnsuApi = createFakeHttpClient(apiRoutes);
+ const repository = createObjectsRepository({ sharePoint, tnsuApi, siteUrl: SITE, apiBaseUrl: API });
+ return { sharePoint, tnsuApi, repository };
+}
+
+function bodyOf(client: IFakeHttpClient, index: number): unknown {
+ return JSON.parse(String(client.requests[index].request.body));
+}
+
+describe('адреса запросов (1:1 со stroy-bot-objects)', () => {
+ it('название проекта по id', () => {
+ expect(projectTitleUrl(SITE, '5')).toEqual(
+ `${SITE}/_api/web/lists/getbytitle('${PROJECTS}')/items(5)?$select=Id,Title`
+ );
+ });
+
+ it('объекты проекта: фильтр по названию закодирован, апостроф удвоен', () => {
+ expect(objectsByProjectUrl(SITE, "ЖК 'Сункар'")).toEqual(
+ `${SITE}/_api/web/lists/getbytitle('${OBJECTS}')/items` +
+ `?$select=Id,Title,parent/Id&$expand=parent` +
+ `&$filter=${encodeURIComponent("project eq 'ЖК ''Сункар'''")}` +
+ `&$top=5000&$orderby=Title`
+ );
+ });
+
+ it('«Доступы» по projectId и userId (название списка не кодируется)', () => {
+ expect(accessUrl(SITE, '5', 42)).toEqual(
+ `${SITE}/_api/web/lists/getbytitle('Доступы')/items` +
+ `?$select=Id,edit0&$top=1&$filter=projectId eq 5 and userId eq 42`
+ );
+ });
+
+ it('элементы, элемент и тип элементов списка «Объекты»', () => {
+ expect(objectsListItemsUrl(SITE)).toEqual(`${SITE}/_api/web/lists/getbytitle('${OBJECTS}')/items`);
+ expect(objectItemUrl(SITE, 7)).toEqual(`${SITE}/_api/web/lists/getbytitle('${OBJECTS}')/items(7)`);
+ expect(objectsEntityTypeUrl(SITE)).toEqual(
+ `${SITE}/_api/web/lists/getbytitle('${OBJECTS}')?$select=ListItemEntityTypeFullName`
+ );
+ });
+
+ it('выгрузка Excel', () => {
+ expect(projectObjectsExportUrl(API, '5')).toEqual(`${API}/Report/ProjectObjects?projectId=5`);
+ });
+});
+
+describe('чтение', () => {
+ it('название проекта; не найден — «Проект с ID=… не найден»', async () => {
+ const ok = setup([{ match: 'items(5)', response: { body: { Id: 5, Title: 'Сункар' } } }]);
+ await expect(ok.repository.fetchProjectTitle('5')).resolves.toEqual('Сункар');
+
+ const missing = setup([{ match: 'items(6)', response: { status: 404 } }]);
+ await expect(missing.repository.fetchProjectTitle('6')).rejects.toThrow('Проект с ID=6 не найден');
+ });
+
+ it('объекты: parent/Id → parentId, пустой lookup → корень', async () => {
+ const { repository } = setup([
+ {
+ match: '$expand=parent',
+ response: { body: { value: [{ Id: 1, Title: 'А', parent: null }, { Id: 2, Title: 'Б', parent: { Id: 1 } }] } }
+ }
+ ]);
+ await expect(repository.fetchObjects('Сункар')).resolves.toEqual([
+ { id: 1, title: 'А', parentId: undefined },
+ { id: 2, title: 'Б', parentId: 1 }
+ ]);
+ });
+
+ it('ошибка объектов: текст из error.message, иначе «Ошибка загрузки списка "Объекты"»', async () => {
+ const withMessage = setup([
+ { match: '$expand=parent', response: { status: 400, body: { error: { message: 'Поле не найдено' } } } }
+ ]);
+ await expect(withMessage.repository.fetchObjects('X')).rejects.toThrow('Поле не найдено');
+
+ const withoutMessage = setup([{ match: '$expand=parent', response: { status: 500, body: {} } }]);
+ await expect(withoutMessage.repository.fetchObjects('X')).rejects.toThrow('Ошибка загрузки списка "Объекты"');
+ });
+});
+
+describe('права (Доступы → edit0)', () => {
+ const access = (body: unknown, status: number = 200): IFakeRoute[] => [
+ { match: 'currentuser', response: { body: { Id: 42 } } },
+ { match: "getbytitle('Доступы')", response: { status, body } }
+ ];
+
+ it('первая запись с edit0 = true — можно редактировать', async () => {
+ const { repository, sharePoint } = setup(access({ value: [{ Id: 1, edit0: true }] }));
+ await expect(repository.fetchCanEdit('5')).resolves.toBe(true);
+ expect(sharePoint.requests.map((request) => request.url)).toEqual([
+ `${SITE}/_api/web/currentuser?$select=Id`,
+ accessUrl(SITE, '5', 42)
+ ]);
+ });
+
+ it('нет записи, edit0 = false или ошибка — нельзя', async () => {
+ await expect(setup(access({ value: [] })).repository.fetchCanEdit('5')).resolves.toBe(false);
+ await expect(setup(access({ value: [{ Id: 1, edit0: false }] })).repository.fetchCanEdit('5')).resolves.toBe(false);
+ await expect(setup(access({}, 500)).repository.fetchCanEdit('5')).resolves.toBe(false);
+ await expect(setup([]).repository.fetchCanEdit('5')).resolves.toBe(false);
+ });
+});
+
+describe('запись', () => {
+ it('создание: тип элемента запрашивается один раз, verbose-заголовки, project = название проекта', async () => {
+ const { repository, sharePoint } = setup([ENTITY_TYPE_ROUTE, { match: '/items', response: { status: 201 } }]);
+
+ await repository.createObject('Сункар', 'Корпус 1', undefined);
+ await repository.createObject('Сункар', 'Секция А', 1);
+
+ expect(sharePoint.requests.map((request) => request.url)).toEqual([
+ objectsEntityTypeUrl(SITE),
+ objectsListItemsUrl(SITE),
+ objectsListItemsUrl(SITE)
+ ]);
+ expect(sharePoint.requests[1].request).toMatchObject({ method: 'POST', headers: VERBOSE_HEADERS });
+ expect(bodyOf(sharePoint, 1)).toEqual({ __metadata: { type: ENTITY_TYPE }, Title: 'Корпус 1', project: 'Сункар' });
+ expect(bodyOf(sharePoint, 2)).toEqual({
+ __metadata: { type: ENTITY_TYPE },
+ Title: 'Секция А',
+ project: 'Сункар',
+ parentId: 1
+ });
+ });
+
+ it('правка: MERGE, parentId — только при смене родителя', async () => {
+ const { repository, sharePoint } = setup([ENTITY_TYPE_ROUTE, { match: '/items(', response: { status: 204 } }]);
+
+ await repository.updateObject(7, 'Новое', undefined);
+ await repository.updateObject(7, 'Новое', 3);
+
+ expect(sharePoint.requests[1]).toEqual({
+ url: objectItemUrl(SITE, 7),
+ request: {
+ method: 'POST',
+ headers: MERGE_HEADERS,
+ body: JSON.stringify({ __metadata: { type: ENTITY_TYPE }, Title: 'Новое' })
+ }
+ });
+ expect(bodyOf(sharePoint, 2)).toEqual({ __metadata: { type: ENTITY_TYPE }, Title: 'Новое', parentId: 3 });
+ });
+
+ it('перенос: меняется только parentId, в корень — null', async () => {
+ const { repository, sharePoint } = setup([ENTITY_TYPE_ROUTE, { match: '/items(', response: { status: 204 } }]);
+
+ await repository.moveObject(7, 2);
+ await repository.moveObject(7, undefined);
+
+ expect(sharePoint.requests[1].request.headers).toEqual(MERGE_HEADERS);
+ expect(bodyOf(sharePoint, 1)).toEqual({ __metadata: { type: ENTITY_TYPE }, parentId: 2 });
+ expect(bodyOf(sharePoint, 2)).toEqual({ __metadata: { type: ENTITY_TYPE }, parentId: null });
+ });
+
+ it('ошибка записи: error.message.value → error.message → statusText', async () => {
+ const verbose = setup([
+ ENTITY_TYPE_ROUTE,
+ { match: '/items', response: { status: 400, body: { error: { message: { value: 'Нет доступа' } } } } }
+ ]);
+ await expect(verbose.repository.createObject('П', 'Т', undefined)).rejects.toThrow('Нет доступа');
+
+ const plain = setup([
+ ENTITY_TYPE_ROUTE,
+ { match: '/items', response: { status: 400, body: { error: { message: 'Плохо' } } } }
+ ]);
+ await expect(plain.repository.createObject('П', 'Т', undefined)).rejects.toThrow('Плохо');
+
+ const empty = setup([ENTITY_TYPE_ROUTE, { match: '/items', response: { status: 500, body: {} } }]);
+ await expect(empty.repository.createObject('П', 'Т', undefined)).rejects.toThrow('Error');
+ });
+
+ it('удаление: DELETE с IF-MATCH и X-HTTP-Method; ошибка — statusText', async () => {
+ const ok = setup([{ match: '/items(9)', response: { status: 200 } }]);
+ await ok.repository.deleteObject(9);
+ expect(ok.sharePoint.requests[0]).toEqual({
+ url: objectItemUrl(SITE, 9),
+ request: { method: 'DELETE', headers: { 'IF-MATCH': '*', 'X-HTTP-Method': 'DELETE' } }
+ });
+
+ const failed = setup([{ match: '/items(9)', response: { status: 403 } }]);
+ await expect(failed.repository.deleteObject(9)).rejects.toThrow('Error');
+ });
+});
+
+describe('выгрузка Excel', () => {
+ it('файл из корпоративного API; ошибка — «Ошибка сервера: {status}»', async () => {
+ const ok = setup([], [{ match: 'ProjectObjects', response: { body: 'xlsx' } }]);
+ await expect(ok.repository.downloadObjectsExcel('5')).resolves.toBeDefined();
+ expect(ok.tnsuApi.requests[0].url).toEqual(`${API}/Report/ProjectObjects?projectId=5`);
+
+ const failed = setup([], [{ match: 'ProjectObjects', response: { status: 502 } }]);
+ await expect(failed.repository.downloadObjectsExcel('5')).rejects.toThrow('Ошибка сервера: 502');
+ });
+});
diff --git a/src/webparts/stroyBot/pages/objects/objectsRepository.ts b/src/webparts/stroyBot/pages/objects/objectsRepository.ts
new file mode 100644
index 0000000..6e34ea0
--- /dev/null
+++ b/src/webparts/stroyBot/pages/objects/objectsRepository.ts
@@ -0,0 +1,222 @@
+import { IHttpClient, IHttpResponse } from '../../services/http/IHttpClient';
+import { fetchCurrentUserId, ISpCollection, listItemsUrl } from '../../shared/sharepoint/spRest';
+import { LISTS } from '../../config/lists';
+import { IObjectRow, IProjectObject, ISpErrorBody } from './objectsTypes';
+import { OBJECTS_TEXTS } from './texts';
+
+// Запросы страницы «Объекты» — 1:1 со stroy-bot-objects (ObjectsByProjectWebPart.ts).
+// Название списка в адресе кодируется encodeURIComponent, как в старом коде; у «Доступы» — нет.
+
+/** Предел выборки «Объекты» — как в старом коде. */
+const OBJECTS_LIMIT = 5000;
+
+/** Внутренние имена полей списка «Объекты». */
+const OBJECT_FIELDS = {
+ title: 'Title',
+ /** Текстовое поле с названием проекта (не lookup). */
+ project: 'project',
+ /** Self-lookup на родителя: читается как `parent/Id`, пишется как `parentId`. */
+ parentWrite: 'parentId'
+} as const;
+
+/** Заголовки записи — те же, что в старом коде (verbose OData). */
+const VERBOSE_HEADERS: Record = {
+ Accept: 'application/json;odata=verbose',
+ 'Content-Type': 'application/json;odata=verbose',
+ 'odata-version': ''
+};
+
+const MERGE_HEADERS: Record = {
+ ...VERBOSE_HEADERS,
+ 'IF-MATCH': '*',
+ 'X-HTTP-Method': 'MERGE'
+};
+
+const DELETE_HEADERS: Record = {
+ 'IF-MATCH': '*',
+ 'X-HTTP-Method': 'DELETE'
+};
+
+const encodedObjectsList = (): string => encodeURIComponent(LISTS.objects);
+
+export function projectTitleUrl(siteUrl: string, projectId: string): string {
+ return `${listItemsUrl(siteUrl, encodeURIComponent(LISTS.projects))}(${projectId})?$select=Id,Title`;
+}
+
+export function objectsByProjectUrl(siteUrl: string, projectTitle: string): string {
+ const filter = encodeURIComponent(`${OBJECT_FIELDS.project} eq '${projectTitle.replace(/'/g, "''")}'`);
+ return (
+ `${listItemsUrl(siteUrl, encodedObjectsList())}` +
+ `?$select=Id,Title,parent/Id&$expand=parent&$filter=${filter}&$top=${OBJECTS_LIMIT}&$orderby=Title`
+ );
+}
+
+export function accessUrl(siteUrl: string, projectId: string, userId: number): string {
+ return (
+ `${listItemsUrl(siteUrl, LISTS.access)}` +
+ `?$select=Id,edit0&$top=1` +
+ `&$filter=projectId eq ${projectId} and userId eq ${userId}`
+ );
+}
+
+export function objectsListItemsUrl(siteUrl: string): string {
+ return listItemsUrl(siteUrl, encodedObjectsList());
+}
+
+export function objectItemUrl(siteUrl: string, itemId: number): string {
+ return `${objectsListItemsUrl(siteUrl)}(${itemId})`;
+}
+
+export function objectsEntityTypeUrl(siteUrl: string): string {
+ return `${siteUrl}/_api/web/lists/getbytitle('${encodedObjectsList()}')?$select=ListItemEntityTypeFullName`;
+}
+
+export function projectObjectsExportUrl(apiBaseUrl: string, projectId: string): string {
+ return `${apiBaseUrl}/Report/ProjectObjects?projectId=${projectId}`;
+}
+
+export function toProjectObject(row: IObjectRow): IProjectObject {
+ return { id: row.Id, title: row.Title, parentId: row.parent?.Id || undefined };
+}
+
+/** Текст ошибки записи: `error.message.value` (verbose) → `error.message` → statusText — как в старом коде. */
+async function readWriteError(response: IHttpResponse): Promise {
+ const body = (await response.json().catch(() => undefined)) as ISpErrorBody | undefined;
+ const message = body?.error?.message;
+ const value = typeof message === 'object' ? message.value : undefined;
+ return value || (message ? String(message) : '') || response.statusText;
+}
+
+export interface IObjectsRepositoryDeps {
+ sharePoint: IHttpClient;
+ tnsuApi: IHttpClient;
+ siteUrl: string;
+ apiBaseUrl: string;
+}
+
+async function fetchProjectTitle(deps: IObjectsRepositoryDeps, projectId: string): Promise {
+ const response = await deps.sharePoint.fetch(projectTitleUrl(deps.siteUrl, projectId));
+ if (!response.ok) throw new Error(OBJECTS_TEXTS.projectNotFound(projectId));
+ const data = (await response.json()) as { Title: string };
+ return data.Title;
+}
+
+async function fetchObjects(deps: IObjectsRepositoryDeps, projectTitle: string): Promise {
+ const response = await deps.sharePoint.fetch(objectsByProjectUrl(deps.siteUrl, projectTitle));
+ if (!response.ok) {
+ const body = (await response.json().catch(() => undefined)) as ISpErrorBody | undefined;
+ const message = body?.error?.message;
+ throw new Error(message ? String(message) : OBJECTS_TEXTS.listLoadError(LISTS.objects));
+ }
+ const data = (await response.json()) as Partial>;
+ return (data.value ?? []).map(toProjectObject);
+}
+
+/** Право редактирования — первая запись «Доступы» проекта и пользователя с edit0. Любая ошибка — нет права. */
+async function fetchCanEdit(deps: IObjectsRepositoryDeps, projectId: string): Promise {
+ try {
+ const userId = await fetchCurrentUserId(deps.sharePoint, deps.siteUrl);
+ const response = await deps.sharePoint.fetch(accessUrl(deps.siteUrl, projectId, userId));
+ if (!response.ok) return false;
+ const data = (await response.json()) as ISpCollection<{ Id: number; edit0: boolean }>;
+ return data.value.length > 0 && data.value[0].edit0 === true;
+ } catch {
+ return false;
+ }
+}
+
+/** Тип элементов списка для __metadata. Ответ не проверяется на ok — как в старом коде. */
+async function fetchEntityType(deps: IObjectsRepositoryDeps): Promise {
+ const response = await deps.sharePoint.fetch(objectsEntityTypeUrl(deps.siteUrl));
+ const data = (await response.json()) as { ListItemEntityTypeFullName?: string } | undefined;
+ return data?.ListItemEntityTypeFullName;
+}
+
+async function postVerbose(
+ deps: IObjectsRepositoryDeps,
+ url: string,
+ headers: Record,
+ body: Record
+): Promise {
+ const response = await deps.sharePoint.fetch(url, { method: 'POST', headers, body: JSON.stringify(body) });
+ if (!response.ok) {
+ throw new Error(await readWriteError(response));
+ }
+}
+
+async function deleteObject(deps: IObjectsRepositoryDeps, itemId: number): Promise {
+ const response = await deps.sharePoint.fetch(objectItemUrl(deps.siteUrl, itemId), {
+ method: 'DELETE',
+ headers: DELETE_HEADERS
+ });
+ if (!response.ok) throw new Error(response.statusText);
+}
+
+async function downloadObjectsExcel(deps: IObjectsRepositoryDeps, projectId: string): Promise {
+ const response = await deps.tnsuApi.fetch(projectObjectsExportUrl(deps.apiBaseUrl, projectId));
+ if (!response.ok) throw new Error(OBJECTS_TEXTS.serverError(response.status));
+ return response.blob();
+}
+
+export interface IObjectsRepository {
+ fetchProjectTitle(projectId: string): Promise;
+ fetchObjects(projectTitle: string): Promise;
+ fetchCanEdit(projectId: string): Promise;
+ createObject(projectTitle: string, title: string, parentId: number | undefined): Promise;
+ /** newParentId — только при смене родителя Раздела; undefined — поле не трогаем. */
+ updateObject(itemId: number, title: string, newParentId: number | undefined): Promise;
+ /** Перенос (drag & drop): меняется только родитель; undefined — в корень. */
+ moveObject(itemId: number, newParentId: number | undefined): Promise;
+ deleteObject(itemId: number): Promise;
+ downloadObjectsExcel(projectId: string): Promise;
+}
+
+/**
+ * Репозиторий хранит кэш типа элементов списка (ListItemEntityTypeFullName) —
+ * как старый web part: тип запрашивается один раз, перед первой записью.
+ */
+export function createObjectsRepository(deps: IObjectsRepositoryDeps): IObjectsRepository {
+ let entityTypePromise: Promise | undefined;
+
+ // Неудачный запрос не кэшируется — следующая запись спросит тип снова, как в старом коде.
+ const getEntityType = (): Promise => {
+ if (!entityTypePromise) {
+ entityTypePromise = fetchEntityType(deps).catch((error: unknown) => {
+ entityTypePromise = undefined;
+ throw error;
+ });
+ }
+ return entityTypePromise;
+ };
+
+ const metadata = async (): Promise<{ __metadata: { type: string | undefined } }> => ({
+ __metadata: { type: await getEntityType() }
+ });
+
+ return {
+ fetchProjectTitle: (projectId) => fetchProjectTitle(deps, projectId),
+ fetchObjects: (projectTitle) => fetchObjects(deps, projectTitle),
+ fetchCanEdit: (projectId) => fetchCanEdit(deps, projectId),
+ createObject: async (projectTitle, title, parentId) => {
+ const body: Record = {
+ ...(await metadata()),
+ [OBJECT_FIELDS.title]: title,
+ [OBJECT_FIELDS.project]: projectTitle
+ };
+ if (parentId !== undefined) body[OBJECT_FIELDS.parentWrite] = parentId;
+ await postVerbose(deps, objectsListItemsUrl(deps.siteUrl), VERBOSE_HEADERS, body);
+ },
+ updateObject: async (itemId, title, newParentId) => {
+ const body: Record = { ...(await metadata()), [OBJECT_FIELDS.title]: title };
+ if (newParentId !== undefined) body[OBJECT_FIELDS.parentWrite] = newParentId;
+ await postVerbose(deps, objectItemUrl(deps.siteUrl, itemId), MERGE_HEADERS, body);
+ },
+ moveObject: async (itemId, newParentId) => {
+ // Перенос в корень сбрасывает lookup явным null.
+ const body = { ...(await metadata()), [OBJECT_FIELDS.parentWrite]: newParentId ?? null };
+ await postVerbose(deps, objectItemUrl(deps.siteUrl, itemId), MERGE_HEADERS, body);
+ },
+ deleteObject: (itemId) => deleteObject(deps, itemId),
+ downloadObjectsExcel: (projectId) => downloadObjectsExcel(deps, projectId)
+ };
+}
diff --git a/src/webparts/stroyBot/pages/objects/objectsRules.test.ts b/src/webparts/stroyBot/pages/objects/objectsRules.test.ts
new file mode 100644
index 0000000..4483052
--- /dev/null
+++ b/src/webparts/stroyBot/pages/objects/objectsRules.test.ts
@@ -0,0 +1,143 @@
+import {
+ buildObjectTree,
+ canAddChild,
+ canDrop,
+ getChangedParentId,
+ getDeleteConfirmMessage,
+ getErrorText,
+ getModalTitle,
+ getParentDisplay,
+ getParentOptions,
+ getSubmitLabel,
+ isParentSelectable,
+ resolveMove
+} from './objectsRules';
+import { buildObjectMenuItems } from './objectMenu';
+import { IProjectObject, ObjectModalMode, ObjectNode, ObjectTree } from './objectsTypes';
+
+// 1 Корпус Б (Объект-пятно)
+// ├─ 2 Секция (Раздел)
+// │ └─ 4 Бетон (Вид работ)
+// └─ 3 Кровля (Раздел)
+// 5 Корпус А (Объект-пятно)
+const OBJECTS: IProjectObject[] = [
+ { id: 1, title: 'Корпус Б', parentId: undefined },
+ { id: 2, title: 'Секция', parentId: 1 },
+ { id: 3, title: 'Кровля', parentId: 1 },
+ { id: 4, title: 'Бетон', parentId: 2 },
+ { id: 5, title: 'Корпус А', parentId: undefined }
+];
+
+const tree: ObjectTree = buildObjectTree(OBJECTS);
+const node = (id: number): ObjectNode => {
+ const found = tree.byId.get(id);
+ if (!found) throw new Error(`нет узла ${id}`);
+ return found;
+};
+const target = (mode: ObjectModalMode, id?: number): { mode: ObjectModalMode; node: ObjectNode | undefined } => ({
+ mode,
+ node: id === undefined ? undefined : node(id)
+});
+
+describe('уровни и меню', () => {
+ it('добавлять дочерние можно в Объект-пятно и Раздел, но не в Вид работ', () => {
+ expect([1, 2, 4].map((id) => canAddChild(node(id)))).toEqual([true, true, false]);
+ });
+
+ it('меню узла: Добавить / Редактировать / Работы / Удалить, у Вида работ — без «Добавить»', () => {
+ expect(buildObjectMenuItems(node(1)).map((item) => item.label)).toEqual([
+ 'Добавить объект',
+ 'Редактировать',
+ 'Работы',
+ 'Удалить'
+ ]);
+ expect(buildObjectMenuItems(node(4)).map((item) => item.key)).toEqual(['edit', 'works', 'delete']);
+ });
+});
+
+describe('модалка', () => {
+ it('заголовки по режиму и уровню', () => {
+ expect(getModalTitle(target('addRoot'))).toEqual('Добавить Объект-пятно');
+ expect(getModalTitle(target('add', 1))).toEqual('Добавить Раздел');
+ expect(getModalTitle(target('add', 2))).toEqual('Добавить Вид работ');
+ expect(getModalTitle(target('add', 4))).toEqual('Добавить объект');
+ expect(getModalTitle(target('edit', 1))).toEqual('Редактировать Объект-пятно');
+ expect(getModalTitle(target('edit', 2))).toEqual('Редактировать Раздел');
+ expect(getModalTitle(target('edit', 4))).toEqual('Редактировать Вид работ');
+ });
+
+ it('кнопка: «Добавить» при добавлении, «Сохранить» при правке', () => {
+ expect(getSubmitLabel(target('addRoot'))).toEqual('Добавить');
+ expect(getSubmitLabel(target('add', 1))).toEqual('Добавить');
+ expect(getSubmitLabel(target('edit', 1))).toEqual('Сохранить');
+ });
+
+ it('родителя можно выбирать только при правке Раздела', () => {
+ expect(isParentSelectable(target('edit', 2))).toBe(true);
+ expect(isParentSelectable(target('edit', 1))).toBe(false);
+ expect(isParentSelectable(target('edit', 4))).toBe(false);
+ expect(isParentSelectable(target('add', 2))).toBe(false);
+ });
+
+ it('родитель в режиме просмотра: при добавлении — сам узел, при правке — родитель или «— (корневой)»', () => {
+ expect(getParentDisplay(target('add', 2), tree)).toEqual('Секция');
+ expect(getParentDisplay(target('edit', 4), tree)).toEqual('Секция');
+ expect(getParentDisplay(target('edit', 1), tree)).toEqual('— (корневой)');
+ });
+
+ it('кандидаты в родители Раздела — Объекты-пятна по алфавиту', () => {
+ expect(getParentOptions(node(2), tree)).toEqual([
+ { value: '5', label: 'Корпус А' },
+ { value: '1', label: 'Корпус Б' }
+ ]);
+ });
+
+ it('новый родитель — только если выбран другой', () => {
+ expect(getChangedParentId(node(2), '5')).toEqual(5);
+ expect(getChangedParentId(node(2), '1')).toBeUndefined();
+ expect(getChangedParentId(node(2), '')).toBeUndefined();
+ expect(getChangedParentId(node(4), '5')).toBeUndefined();
+ });
+});
+
+describe('перетаскивание', () => {
+ it('нельзя бросить в себя и в своих потомков', () => {
+ expect(canDrop(tree, 1, 1)).toBe(false);
+ expect(canDrop(tree, 1, 4)).toBe(false);
+ expect(canDrop(tree, 4, 1)).toBe(true);
+ });
+
+ it('«внутрь» — родитель = цель, «до/после» — родитель цели', () => {
+ expect(resolveMove(tree, 4, 3, 'inside')).toEqual({ parentId: 3 });
+ expect(resolveMove(tree, 4, 3, 'before')).toEqual({ parentId: 1 });
+ expect(resolveMove(tree, 2, 5, 'after')).toEqual({ parentId: undefined });
+ });
+
+ it('родитель не меняется или перенос запрещён — ничего не делаем', () => {
+ expect(resolveMove(tree, 2, 3, 'after')).toBeUndefined();
+ expect(resolveMove(tree, 1, 5, 'before')).toBeUndefined();
+ expect(resolveMove(tree, 1, 4, 'inside')).toBeUndefined();
+ });
+});
+
+describe('удаление и ошибки', () => {
+ it('подтверждение с числом дочерних', () => {
+ expect(getDeleteConfirmMessage(node(1))).toEqual(
+ 'Удалить объект «Корпус Б»?\n\n⚠️ У этого объекта есть дочерние элементы (2 шт.).\n\nЭто действие нельзя отменить.'
+ );
+ expect(getDeleteConfirmMessage(node(4))).toEqual(
+ 'Удалить объект «Бетон»?\n\nЭто действие нельзя отменить.'
+ );
+ });
+
+ it('без названия — «ID n»', () => {
+ const untitled = buildObjectTree([{ id: 8, title: '', parentId: undefined }]);
+ const untitledNode = untitled.byId.get(8);
+ expect(untitledNode && getDeleteConfirmMessage(untitledNode)).toContain('«ID 8»');
+ });
+
+ it('текст ошибки — message для Error, иначе строка', () => {
+ expect(getErrorText(new Error('сбой'))).toEqual('сбой');
+ expect(getErrorText('текст')).toEqual('текст');
+ });
+});
diff --git a/src/webparts/stroyBot/pages/objects/objectsRules.ts b/src/webparts/stroyBot/pages/objects/objectsRules.ts
new file mode 100644
index 0000000..e60857e
--- /dev/null
+++ b/src/webparts/stroyBot/pages/objects/objectsRules.ts
@@ -0,0 +1,112 @@
+import { ISelectOption } from '@tnsu/ui-kit';
+import { buildTree, isDescendant } from '../_tree/treeRules';
+import { DropPosition, IObjectModalTarget, IProjectObject, ObjectNode, ObjectTree } from './objectsTypes';
+import { OBJECTS_TEXTS } from './texts';
+
+// Правила страницы «Объекты» (stroy-bot-objects), вынесенные из DOM-кода в чистые функции.
+
+const TEXTS = OBJECTS_TEXTS.modal;
+
+/**
+ * Уровни дерева: 0 — Объект-пятно, 1 — Раздел, 2 — Вид работ.
+ * Добавлять дочерние можно только в узлы уровней 0 и 1 — дерево не глубже трёх уровней.
+ */
+export const SECTION_LEVEL = 1;
+const MAX_PARENT_LEVEL = 1;
+
+export function buildObjectTree(objects: IProjectObject[]): ObjectTree {
+ return buildTree(
+ objects,
+ (object) => object.id,
+ (object) => object.parentId
+ );
+}
+
+/** Пункт «Добавить объект» в меню узла. */
+export function canAddChild(node: ObjectNode): boolean {
+ return node.level <= MAX_PARENT_LEVEL;
+}
+
+export function getModalTitle({ mode, node }: IObjectModalTarget): string {
+ const level = node?.level ?? 0;
+ if (mode === 'addRoot') return TEXTS.addRootTitle;
+ if (mode === 'add') return TEXTS.addTitle(TEXTS.childLabels[level] ?? TEXTS.fallbackLabel);
+ return TEXTS.editTitle(TEXTS.levelLabels[level] ?? TEXTS.fallbackLabel);
+}
+
+export function getSubmitLabel({ mode }: IObjectModalTarget): string {
+ return mode === 'edit' ? TEXTS.save : TEXTS.add;
+}
+
+/** У Раздела при правке родителя можно сменить (select); у остальных — только просмотр. */
+export function isParentSelectable({ mode, node }: IObjectModalTarget): boolean {
+ return mode === 'edit' && node?.level === SECTION_LEVEL;
+}
+
+/**
+ * Текст поля «Родительский объект» в режиме просмотра:
+ * при добавлении — сам узел (или «—»), при правке — его родитель (или «— (корневой)»).
+ */
+export function getParentDisplay({ mode, node }: IObjectModalTarget, tree: ObjectTree): string {
+ if (mode === 'add') return node?.item.title || TEXTS.parentEmpty;
+ const parent = node?.item.parentId ? tree.byId.get(node.item.parentId) : undefined;
+ return parent ? parent.item.title : TEXTS.parentRoot;
+}
+
+/** Кандидаты в родители Раздела: Объекты-пятна, кроме самого узла и его потомков, по алфавиту (ru). */
+export function getParentOptions(node: ObjectNode, tree: ObjectTree): ISelectOption[] {
+ const candidates: ObjectNode[] = [];
+ tree.byId.forEach((candidate) => {
+ if (candidate.level === 0 && candidate.id !== node.id && !isDescendant(tree.byId, node.id, candidate.id)) {
+ candidates.push(candidate);
+ }
+ });
+ candidates.sort((a, b) => a.item.title.localeCompare(b.item.title, 'ru'));
+ return candidates.map((candidate) => ({ value: String(candidate.id), label: candidate.item.title }));
+}
+
+/** Новый родитель Раздела из select — только если он выбран и изменился; иначе поле не трогаем. */
+export function getChangedParentId(node: ObjectNode | undefined, selectValue: string): number | undefined {
+ if (!node || node.level !== SECTION_LEVEL || !selectValue) return undefined;
+ const parentId = parseInt(selectValue, 10);
+ return !isNaN(parentId) && parentId !== node.item.parentId ? parentId : undefined;
+}
+
+/** Бросать узел можно куда угодно, кроме себя и своих потомков. */
+export function canDrop(tree: ObjectTree, dragId: number, targetId: number): boolean {
+ return dragId !== targetId && !isDescendant(tree.byId, dragId, targetId);
+}
+
+export interface IMoveResult {
+ /** undefined — перенос в корень. */
+ parentId: number | undefined;
+}
+
+/**
+ * Новый родитель после перетаскивания: «внутрь» — целевой узел, «до/после» — родитель целевого.
+ * undefined — переносить нечего (родитель не меняется или перенос запрещён).
+ */
+export function resolveMove(
+ tree: ObjectTree,
+ dragId: number,
+ targetId: number,
+ position: DropPosition
+): IMoveResult | undefined {
+ const dragNode = tree.byId.get(dragId);
+ if (!dragNode || !canDrop(tree, dragId, targetId)) return undefined;
+
+ const parentId = position === 'inside' ? targetId : tree.byId.get(targetId)?.item.parentId;
+ return dragNode.item.parentId === parentId ? undefined : { parentId };
+}
+
+export function getDeleteConfirmMessage(node: ObjectNode): string {
+ return OBJECTS_TEXTS.deleteConfirm(
+ node.item.title || OBJECTS_TEXTS.deleteFallbackTitle(node.id),
+ node.children.length
+ );
+}
+
+/** Текст исключения — как `err instanceof Error ? err.message : String(err)` в старом коде. */
+export function getErrorText(error: unknown): string {
+ return error instanceof Error ? error.message : String(error);
+}
diff --git a/src/webparts/stroyBot/pages/objects/objectsTypes.ts b/src/webparts/stroyBot/pages/objects/objectsTypes.ts
new file mode 100644
index 0000000..dfc6f9c
--- /dev/null
+++ b/src/webparts/stroyBot/pages/objects/objectsTypes.ts
@@ -0,0 +1,41 @@
+import { ITree, ITreeNode } from '../_tree/treeRules';
+
+/** Элемент списка «Объекты». */
+export interface IProjectObject {
+ id: number;
+ title: string;
+ /** Id родителя (lookup `parent`); undefined — корневой объект. */
+ parentId: number | undefined;
+}
+
+export type ObjectNode = ITreeNode;
+export type ObjectTree = ITree;
+
+/**
+ * Режим модалки: addRoot — новый Объект-пятно, add — дочерний объект узла, edit — правка узла.
+ */
+export type ObjectModalMode = 'add' | 'edit' | 'addRoot';
+
+export interface IObjectModalTarget {
+ mode: ObjectModalMode;
+ /** Узел, для которого открыта модалка; для addRoot — undefined. */
+ node: ObjectNode | undefined;
+}
+
+/** Куда бросили узел относительно целевого: до, внутрь, после. */
+export type DropPosition = 'before' | 'inside' | 'after';
+
+/**
+ * Строка «Объекты» в ответе SharePoint ($select=Id,Title,parent/Id&$expand=parent).
+ * Пустой lookup приходит как null — читается через `?.`.
+ */
+export interface IObjectRow {
+ Id: number;
+ Title: string;
+ parent?: { Id?: number };
+}
+
+/** Ответ SharePoint с ошибкой: verbose — message.value, иначе — строка message. */
+export interface ISpErrorBody {
+ error?: { message?: string | { value?: string } };
+}
diff --git a/src/webparts/stroyBot/pages/objects/texts.ts b/src/webparts/stroyBot/pages/objects/texts.ts
new file mode 100644
index 0000000..8917434
--- /dev/null
+++ b/src/webparts/stroyBot/pages/objects/texts.ts
@@ -0,0 +1,64 @@
+// Тексты страницы «Объекты» — дословно из stroy-bot-objects (ObjectsByProjectWebPart.ts).
+
+export const OBJECTS_TEXTS = {
+ header: {
+ label: 'Объекты проекта',
+ titleLoading: 'Загрузка...',
+ titleError: 'Ошибка',
+ addRoot: 'Добавить объект',
+ addRootTooltip: 'Добавить корневой объект',
+ export: 'Экспорт',
+ exporting: 'Экспортируется...',
+ exportTooltip: 'Скачать в Excel'
+ },
+ loading: 'Загружаем данные...',
+ noProject: 'Вы не выбрали проект',
+ projectNotFound: (projectId: string): string => `Проект с ID=${projectId} не найден`,
+ listLoadError: (listName: string): string => `Ошибка загрузки списка "${listName}"`,
+ loadError: (message: string): string => `Ошибка: ${message}`,
+ empty: 'Объекты не найдены',
+ emptyAdd: '+ Добавить объект',
+ toolbar: {
+ total: (count: number): string => `${count} объектов`,
+ expandAll: '↕ Развернуть',
+ collapseAll: '↙ Свернуть'
+ },
+ nodeMenuTooltip: 'Действия',
+ menu: {
+ add: 'Добавить объект',
+ edit: 'Редактировать',
+ works: 'Работы',
+ delete: 'Удалить'
+ },
+ modal: {
+ addRootTitle: 'Добавить Объект-пятно',
+ addTitle: (label: string): string => `Добавить ${label}`,
+ editTitle: (label: string): string => `Редактировать ${label}`,
+ /** Что добавляется в узел уровня N. */
+ childLabels: ['Раздел', 'Вид работ'] as readonly string[],
+ /** Как называется узел уровня N. */
+ levelLabels: ['Объект-пятно', 'Раздел', 'Вид работ'] as readonly string[],
+ fallbackLabel: 'объект',
+ titleLabel: 'Название',
+ titlePlaceholder: 'Введите название объекта',
+ titleRequired: 'Поле обязательно для заполнения',
+ parentLabel: 'Родительский объект',
+ parentEmpty: '—',
+ parentRoot: '— (корневой)',
+ projectLabel: 'Проект',
+ cancel: 'Отмена',
+ add: 'Добавить',
+ save: 'Сохранить'
+ },
+ deleteConfirm: (title: string, childCount: number): string => {
+ const childWarning =
+ childCount > 0 ? `\n\n⚠️ У этого объекта есть дочерние элементы (${childCount} шт.).` : '';
+ return `Удалить объект «${title}»?${childWarning}\n\nЭто действие нельзя отменить.`;
+ },
+ deleteFallbackTitle: (id: number): string => `ID ${id}`,
+ serverError: (status: number): string => `Ошибка сервера: ${status}`,
+ saveError: (message: string): string => `Ошибка сохранения: ${message}`,
+ moveError: (message: string): string => `Не удалось переместить: ${message}`,
+ deleteError: (message: string): string => `Не удалось удалить: ${message}`,
+ exportError: (message: string): string => `Не удалось скачать файл: ${message}`
+} as const;
diff --git a/src/webparts/stroyBot/pages/objects/useObjectActions.ts b/src/webparts/stroyBot/pages/objects/useObjectActions.ts
new file mode 100644
index 0000000..040fc63
--- /dev/null
+++ b/src/webparts/stroyBot/pages/objects/useObjectActions.ts
@@ -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;
+ moveObject: (dragId: number, targetId: number, position: DropPosition) => Promise;
+ deleteObject: (node: ObjectNode) => Promise;
+}
+
+/** Пишущие действия над деревом объектов. После каждого успешного действия дерево перечитывается. */
+export function useObjectActions({ repository, projectTitle, tree, reload }: IActionsDeps): IObjectActions {
+ const [actionError, setActionError] = React.useState(undefined);
+
+ const saveObject = async (target: IObjectModalTarget, title: string, parentValue: string): Promise => {
+ 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 => {
+ 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 => {
+ 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 };
+}
diff --git a/src/webparts/stroyBot/pages/objects/useObjectDrag.ts b/src/webparts/stroyBot/pages/objects/useObjectDrag.ts
new file mode 100644
index 0000000..6b69980
--- /dev/null
+++ b/src/webparts/stroyBot/pages/objects/useObjectDrag.ts
@@ -0,0 +1,72 @@
+import * as React from 'react';
+import { canDrop } from './objectsRules';
+import { DropPosition, ObjectTree } from './objectsTypes';
+
+export interface IDropZone {
+ targetId: number;
+ position: DropPosition;
+}
+
+export interface IObjectDrag {
+ draggingId: number | undefined;
+ /** Подсвеченная зона сброса. */
+ activeZone: IDropZone | undefined;
+ onDragStart: (nodeId: number, event: React.DragEvent) => void;
+ onDragEnd: () => void;
+ onZoneDragOver: (zone: IDropZone, event: React.DragEvent) => void;
+ onZoneDragLeave: () => void;
+ onDrop: (event: React.DragEvent) => void;
+}
+
+/**
+ * Перетаскивание узлов — как в stroy-bot-objects: узел бросается на зону до / внутрь / после
+ * другого узла. Бросок засчитывается в последнюю «разрешённую» зону, над которой был курсор.
+ */
+export function useObjectDrag(
+ tree: ObjectTree,
+ onMove: (dragId: number, targetId: number, position: DropPosition) => void
+): IObjectDrag {
+ const [draggingId, setDraggingId] = React.useState(undefined);
+ const [activeZone, setActiveZone] = React.useState(undefined);
+ // Последняя разрешённая зона: в старом коде dragleave снимал только подсветку, а цель запоминалась.
+ const dropTargetRef = React.useRef(undefined);
+
+ const reset = (): void => {
+ setDraggingId(undefined);
+ setActiveZone(undefined);
+ dropTargetRef.current = undefined;
+ };
+
+ const onDragStart = (nodeId: number, event: React.DragEvent): void => {
+ setDraggingId(nodeId);
+ event.dataTransfer.effectAllowed = 'move';
+ event.dataTransfer.setData('text/plain', String(nodeId));
+ };
+
+ const onZoneDragOver = (zone: IDropZone, event: React.DragEvent): void => {
+ if (draggingId === undefined || !canDrop(tree, draggingId, zone.targetId)) return;
+ event.preventDefault();
+ event.dataTransfer.dropEffect = 'move';
+ setActiveZone(zone);
+ dropTargetRef.current = zone;
+ };
+
+ const onDrop = (event: React.DragEvent): void => {
+ event.preventDefault();
+ const dragId = draggingId;
+ const target = dropTargetRef.current;
+ reset();
+ if (dragId === undefined || !target) return;
+ onMove(dragId, target.targetId, target.position);
+ };
+
+ return {
+ draggingId,
+ activeZone,
+ onDragStart,
+ onDragEnd: reset,
+ onZoneDragOver,
+ onZoneDragLeave: () => setActiveZone(undefined),
+ onDrop
+ };
+}
diff --git a/src/webparts/stroyBot/pages/objects/useObjectTreeView.ts b/src/webparts/stroyBot/pages/objects/useObjectTreeView.ts
new file mode 100644
index 0000000..f832e7e
--- /dev/null
+++ b/src/webparts/stroyBot/pages/objects/useObjectTreeView.ts
@@ -0,0 +1,49 @@
+import * as React from 'react';
+import { collectParentIds, toggleId } from '../_tree/treeRules';
+import { ObjectNode, ObjectTree } from './objectsTypes';
+
+export interface IObjectTreeView {
+ expandedIds: Set;
+ selectedId: number | undefined;
+ /** Шеврон: только раскрыть/свернуть. */
+ toggle: (nodeId: number) => void;
+ /** Клик по строке: выделить и, если есть дети, раскрыть/свернуть. */
+ clickRow: (node: ObjectNode) => void;
+ select: (nodeId: number) => void;
+ expandAll: () => void;
+ collapseAll: () => void;
+}
+
+/**
+ * Раскрытые и выделенный узлы. Живут, пока открыта страница, и переживают перезагрузку
+ * дерева после любой операции — как поля _expandedNodes / _selectedNodeId старого web part.
+ */
+export function useObjectTreeView(tree: ObjectTree): IObjectTreeView {
+ const [expandedIds, setExpandedIds] = React.useState>(() => new Set());
+ const [selectedId, setSelectedId] = React.useState(undefined);
+
+ const toggle = (nodeId: number): void => setExpandedIds((prev) => toggleId(prev, nodeId));
+
+ const clickRow = (node: ObjectNode): void => {
+ setSelectedId(node.id);
+ if (node.children.length > 0) toggle(node.id);
+ };
+
+ const expandAll = (): void => {
+ setExpandedIds((prev) => {
+ const next = new Set(prev);
+ collectParentIds(tree.roots).forEach((id) => next.add(id));
+ return next;
+ });
+ };
+
+ const collapseAll = (): void => {
+ setExpandedIds((prev) => {
+ const next = new Set(prev);
+ collectParentIds(tree.roots).forEach((id) => next.delete(id));
+ return next;
+ });
+ };
+
+ return { expandedIds, selectedId, toggle, clickRow, select: setSelectedId, expandAll, collapseAll };
+}
diff --git a/src/webparts/stroyBot/pages/objects/useObjectsData.ts b/src/webparts/stroyBot/pages/objects/useObjectsData.ts
new file mode 100644
index 0000000..7b8a82d
--- /dev/null
+++ b/src/webparts/stroyBot/pages/objects/useObjectsData.ts
@@ -0,0 +1,71 @@
+import * as React from 'react';
+import { IObjectsRepository } from './objectsRepository';
+import { buildObjectTree, getErrorText } from './objectsRules';
+import { IProjectObject, ObjectTree } from './objectsTypes';
+import { OBJECTS_TEXTS } from './texts';
+
+export type ObjectsLoadStatus = 'loading' | 'ready' | 'error';
+
+interface IObjectsState {
+ status: ObjectsLoadStatus;
+ errorMessage: string | undefined;
+ projectTitle: string | undefined;
+ canEdit: boolean;
+ objects: IProjectObject[];
+}
+
+export interface IObjectsData extends IObjectsState {
+ tree: ObjectTree;
+ /** Перечитать проект, права и объекты. Текущее дерево остаётся на экране до ответа — как в старом коде. */
+ reload: () => void;
+}
+
+const INITIAL_STATE: IObjectsState = {
+ status: 'loading',
+ errorMessage: undefined,
+ projectTitle: undefined,
+ canEdit: false,
+ objects: []
+};
+
+/**
+ * Загрузка страницы в порядке stroy-bot-objects: название проекта → права → объекты.
+ * Название и права показываются сразу, как пришли, даже если объекты потом не загрузятся.
+ */
+export function useObjectsData(repository: IObjectsRepository, projectId: string | undefined): IObjectsData {
+ const [state, setState] = React.useState(INITIAL_STATE);
+ const [reloadKey, setReloadKey] = React.useState(0);
+
+ React.useEffect(() => {
+ let isCancelled = false;
+ const update = (patch: Partial): void => {
+ if (!isCancelled) setState((prev) => ({ ...prev, ...patch }));
+ };
+
+ const load = async (): Promise => {
+ if (!projectId) {
+ update({ status: 'error', errorMessage: OBJECTS_TEXTS.noProject });
+ return;
+ }
+ try {
+ const projectTitle = await repository.fetchProjectTitle(projectId);
+ update({ projectTitle });
+ update({ canEdit: await repository.fetchCanEdit(projectId) });
+ const objects = await repository.fetchObjects(projectTitle);
+ update({ status: 'ready', errorMessage: undefined, objects });
+ } catch (error) {
+ update({ status: 'error', errorMessage: OBJECTS_TEXTS.loadError(getErrorText(error)) });
+ }
+ };
+
+ load().catch(() => undefined);
+ return () => {
+ isCancelled = true;
+ };
+ }, [repository, projectId, reloadKey]);
+
+ const tree = React.useMemo(() => buildObjectTree(state.objects), [state.objects]);
+ const reload = React.useCallback(() => setReloadKey((key) => key + 1), []);
+
+ return { ...state, tree, reload };
+}
diff --git a/src/webparts/stroyBot/pages/objects/useObjectsExport.ts b/src/webparts/stroyBot/pages/objects/useObjectsExport.ts
new file mode 100644
index 0000000..1b266a4
--- /dev/null
+++ b/src/webparts/stroyBot/pages/objects/useObjectsExport.ts
@@ -0,0 +1,34 @@
+import * as React from 'react';
+import { saveFile } from '../../shared/utils/saveFile';
+import { IObjectsRepository } from './objectsRepository';
+import { getErrorText } from './objectsRules';
+import { OBJECTS_TEXTS } from './texts';
+
+export interface IObjectsExport {
+ isExporting: boolean;
+ /** Ошибка выгрузки (раньше — alert). */
+ exportError: string | undefined;
+ exportExcel: () => Promise;
+}
+
+/** Кнопка «Экспорт»: Excel со всеми объектами проекта из корпоративного API — `objects-{projectId}.xlsx`. */
+export function useObjectsExport(repository: IObjectsRepository, projectId: string | undefined): IObjectsExport {
+ const [isExporting, setIsExporting] = React.useState(false);
+ const [exportError, setExportError] = React.useState(undefined);
+
+ const exportExcel = async (): Promise => {
+ if (!projectId) return;
+ setIsExporting(true);
+ setExportError(undefined);
+ try {
+ const blob = await repository.downloadObjectsExcel(projectId);
+ saveFile(blob, `objects-${projectId}.xlsx`);
+ } catch (error) {
+ setExportError(OBJECTS_TEXTS.exportError(getErrorText(error)));
+ } finally {
+ setIsExporting(false);
+ }
+ };
+
+ return { isExporting, exportError, exportExcel };
+}