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
+7 -2
View File
@@ -83,7 +83,7 @@
- [ ] Пусто — «Объекты не найдены» + «+ Добавить объект» (если `canEdit`) - [ ] Пусто — «Объекты не найдены» + «+ Добавить объект» (если `canEdit`)
- [ ] Тулбар «{N} объектов» (рекурсивно), «Развернуть», «Свернуть» - [ ] Тулбар «{N} объектов» (рекурсивно), «Развернуть», «Свернуть»
- [ ] Дерево: отступ, иконка папки/файла, счётчик детей, шеврон, клик по строке — выделить + раскрыть - [ ] Дерево: отступ, иконка папки/файла, счётчик детей, шеврон, клик по строке — выделить + раскрыть
- [ ] Раскрытые узлы сохраняются между перезагрузками в рамках сессии - [ ] Раскрытые и выделенный узлы сохраняются при перезагрузке дерева после операций (в памяти страницы, как в старом коде; при обновлении страницы браузера — сбрасываются)
- [ ] Уровни: 0 — Объект-пятно, 1 — Раздел, 2 — Вид работ (не глубже 3 уровней) - [ ] Уровни: 0 — Объект-пятно, 1 — Раздел, 2 — Вид работ (не глубже 3 уровней)
- [ ] Контекстное меню (⋮ и правый клик, только `canEdit`): Добавить объект (не на уровне 2), Редактировать, Работы, Удалить - [ ] Контекстное меню (⋮ и правый клик, только `canEdit`): Добавить объект (не на уровне 2), Редактировать, Работы, Удалить
- [ ] Модалка: «Название *» (обязательно, Enter — сохранить), «Родительский объект» (select только при правке Раздела: узлы уровня 0, сортировка ru, без себя и потомков), «Проект» (read-only) - [ ] Модалка: «Название *» (обязательно, Enter — сохранить), «Родительский объект» (select только при правке Раздела: узлы уровня 0, сортировка ru, без себя и потомков), «Проект» (read-only)
@@ -95,7 +95,12 @@
- [ ] Список «Объекты»: `Title`, `project` (текст = название проекта), `parent` (self-lookup) - [ ] Список «Объекты»: `Title`, `project` (текст = название проекта), `parent` (self-lookup)
- [ ] Права: «Доступы» `projectId`/`userId` → `edit0` - [ ] Права: «Доступы» `projectId`/`userId` → `edit0`
🐞 Перетаскивание меняет только родителя, порядок не сохраняется. 🐞 Удаление узла не удаляет детей. 🐞 Перетаскивание меняет только родителя, порядок не сохраняется. 🐞 Удаление узла не удаляет детей. 🐞 Зона сброса «внутрь» нулевой высоты — на практике узел можно бросить только «до» / «после» (перенесено как есть).
**Сознательные отличия от objects:**
- `alert()` → сообщение `Alert` на странице (ошибки переноса, удаления, экспорта) или в модалке (ошибка сохранения). Тексты те же.
- `confirm()` удаления → `confirmModal` с кнопкой «Удалить»; текст тот же, но переносы строк ui-kit показывает одним абзацем.
- Адрес сайта — настройка «URL сайта SharePoint» (раньше `pageContext.web.absoluteUrl`), экспорт — через клиент корпоративного API и настройку «Базовый URL API» (по умолчанию тот же `https://api.tnsu.kz`).
--- ---
+3
View File
@@ -6,6 +6,7 @@ import { ResourcesPage } from '../pages/resources/ResourcesPage';
import { BinderPage } from '../pages/binder/BinderPage'; import { BinderPage } from '../pages/binder/BinderPage';
import { ReportsPage } from '../pages/reports/ReportsPage'; import { ReportsPage } from '../pages/reports/ReportsPage';
import { ImportWorksPage } from '../pages/import/ImportWorksPage'; import { ImportWorksPage } from '../pages/import/ImportWorksPage';
import { ObjectsPage } from '../pages/objects/ObjectsPage';
/** Показывает страницу текущего раздела. Разделы подключаются сюда по мере переноса. */ /** Показывает страницу текущего раздела. Разделы подключаются сюда по мере переноса. */
export const PageOutlet: React.FC = () => { export const PageOutlet: React.FC = () => {
@@ -22,6 +23,8 @@ export const PageOutlet: React.FC = () => {
return <ReportsPage />; return <ReportsPage />;
case 'import': case 'import':
return <ImportWorksPage />; return <ImportWorksPage />;
case 'objects':
return <ObjectsPage />;
default: default:
return <SectionPlaceholder />; return <SectionPlaceholder />;
} }
@@ -0,0 +1,55 @@
@import '../../../../styles/design-tokens';
// Ширина меню как в stroy-bot-objects — подписи пунктов не переносятся.
$menu-min-width: 210px;
// Над модалками SharePoint, но под модалками ui-kit (меню закрывается до их открытия).
$menu-z-index: 9999;
.menu {
position: fixed;
z-index: $menu-z-index;
min-width: $menu-min-width;
padding: 6px;
background: $white;
border: 1px solid $gray-200;
border-radius: $r-md;
box-shadow: $sh-modal;
font-family: $font-sans;
user-select: none;
}
.item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 9px $sp-3;
border: 0;
border-radius: $r-sm;
background: transparent;
font-family: inherit;
font-size: $fs-sm;
color: $gray-700;
text-align: left;
cursor: pointer;
&:hover {
background: $gold-50;
color: $navy-900;
}
}
.itemDanger {
color: $red-500;
&:hover {
background: $red-50;
color: $red-500;
}
}
.divider {
height: 1px;
margin: 5px $sp-2;
background: $gray-200;
}
@@ -0,0 +1,87 @@
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import { Icon, IconName } from '@tnsu/ui-kit';
import { useOutsideClose } from '../../shared/hooks/useOutsideClose';
import styles from './ContextMenu.module.scss';
export interface IContextMenuItem<K extends string = string> {
key: K;
label: string;
icon: IconName;
danger?: boolean;
/** Разделитель перед пунктом (как перед «Удалить» в старых web parts). */
dividerBefore?: boolean;
}
/** Точка привязки меню в координатах окна (clientX/clientY). */
export interface IMenuAnchor {
x: number;
y: number;
/** Меню прижимается правым краем к x (под кнопкой ⋮ в таблице работ). */
alignRight?: boolean;
}
interface Props<K extends string> {
anchor: IMenuAnchor;
items: Array<IContextMenuItem<K>>;
onSelect: (key: K) => void;
onClose: () => void;
}
/** Отступ от края окна, при котором меню разворачивается в другую сторону — как в stroy-bot-objects. */
const VIEWPORT_MARGIN = 8;
interface IPosition {
left: number;
top: number;
}
function fitIntoViewport(anchor: IMenuAnchor, width: number, height: number): IPosition {
let left = anchor.alignRight ? anchor.x - width : anchor.x;
let top = anchor.y;
if (left + width > window.innerWidth - VIEWPORT_MARGIN) left = anchor.x - width;
if (top + height > window.innerHeight - VIEWPORT_MARGIN) top = anchor.y - height;
return { left, top };
}
/**
* Всплывающее меню действий над узлом. Рендерится в body (portal), чтобы не обрезаться
* контейнером с прокруткой. Закрывается кликом вне меню и по Esc.
*/
export function ContextMenu<K extends string>({ anchor, items, onSelect, onClose }: Props<K>): React.ReactElement {
const menuRef = React.useRef<HTMLDivElement>(null);
const [position, setPosition] = React.useState<IPosition | undefined>(undefined);
useOutsideClose(menuRef, true, onClose);
// Размер меню известен только после отрисовки — до этого оно невидимо.
React.useLayoutEffect(() => {
if (!menuRef.current) return;
const rect = menuRef.current.getBoundingClientRect();
setPosition(fitIntoViewport(anchor, rect.width, rect.height));
}, [anchor]);
const style: React.CSSProperties = position
? { left: position.left, top: position.top }
: { left: anchor.x, top: anchor.y, visibility: 'hidden' };
return ReactDOM.createPortal(
<div className={styles.menu} style={style} ref={menuRef} role="menu">
{items.map((item) => (
<React.Fragment key={item.key}>
{item.dividerBefore && <div className={styles.divider} />}
<button
type="button"
role="menuitem"
className={item.danger ? `${styles.item} ${styles.itemDanger}` : styles.item}
onClick={() => onSelect(item.key)}
>
<Icon name={item.icon} size="sm" />
{item.label}
</button>
</React.Fragment>
))}
</div>,
document.body
);
}
@@ -0,0 +1,30 @@
@import '../../../../styles/design-tokens';
// Размеры повторяют TextInput из ui-kit, чтобы поля в одной модалке выглядели одинаково.
$input-padding: 11px 13px;
$input-font-size: 13.5px;
.input {
box-sizing: border-box;
width: 100%;
padding: $input-padding;
border: 1px solid $gray-300;
border-radius: $r-sm;
background: $white;
font-family: $font-sans;
font-size: $input-font-size;
color: $gray-900;
transition: border-color 0.18s, box-shadow 0.18s;
@include input-focus-gold;
&::placeholder {
color: $gray-400;
}
}
.invalid,
.invalid:focus {
border-color: $red-500;
box-shadow: 0 0 0 3px rgba($red-500, 0.1);
}
@@ -0,0 +1,52 @@
import * as React from 'react';
import styles from './FormInput.module.scss';
interface Props {
id: string;
type?: 'text' | 'number';
value: string;
onChange: (value: string) => void;
placeholder?: string;
/** Шаг для type="number" (проектный объём — 0.001). */
step?: string;
/** Красная рамка незаполненного обязательного поля. */
isInvalid?: boolean;
/** Выделить текст при фокусе — как при открытии модалки в stroy-bot-objects. */
selectOnFocus?: boolean;
autoFocus?: boolean;
onEnter?: () => void;
}
/**
* Поле ввода модалок «Объекты» / «Работы». TextInput из ui-kit не умеет step, ref (возврат фокуса
* при ошибке) и состояние ошибки, а старые web parts это использовали. Внешний вид — как у TextInput.
*/
export const FormInput = React.forwardRef<HTMLInputElement, Props>(
({ id, type = 'text', value, onChange, placeholder, step, isInvalid, selectOnFocus, autoFocus, onEnter }, ref) => {
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>): void => {
if (event.key === 'Enter' && onEnter) {
event.preventDefault();
onEnter();
}
};
return (
<input
id={id}
ref={ref}
type={type}
className={isInvalid ? `${styles.input} ${styles.invalid}` : styles.input}
value={value}
placeholder={placeholder}
step={step}
autoComplete="off"
autoFocus={autoFocus}
onFocus={selectOnFocus ? (event) => event.target.select() : undefined}
onChange={(event) => onChange(event.target.value)}
onKeyDown={onKeyDown}
/>
);
}
);
FormInput.displayName = 'FormInput';
@@ -0,0 +1,60 @@
@import '../../../../styles/design-tokens';
// Размер золотого квадрата с иконкой — как в шапке stroy-bot-objects.
$icon-box-size: 46px;
.header {
display: flex;
align-items: center;
gap: 18px;
flex-wrap: wrap;
@include page-header;
}
.icon {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: $icon-box-size;
height: $icon-box-size;
border-radius: $r-md;
background: rgba($gold-500, 0.14);
border: 1px solid rgba($gold-500, 0.3);
color: $gold-300;
}
.text {
position: relative;
display: flex;
flex-direction: column;
gap: 3px;
flex: 1;
min-width: 0;
}
.label {
font-size: $fs-overline;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 1.8px;
color: rgba($white, 0.55);
}
.title {
font-family: $font-serif;
font-size: $fs-page-title;
font-weight: 600;
line-height: 1.2;
color: $white;
word-break: break-word;
}
.actions {
position: relative;
display: flex;
align-items: center;
gap: $sp-2;
flex-wrap: wrap;
}
@@ -0,0 +1,26 @@
import * as React from 'react';
import { Icon, IconName } from '@tnsu/ui-kit';
import styles from './TreePageHeader.module.scss';
interface Props {
icon: IconName;
/** Надпись мелким шрифтом над заголовком (необязательна). */
label?: string;
title: string;
/** Кнопки справа. */
actions?: React.ReactNode;
}
/** Тёмная шапка страниц «Объекты» и «Работы» — как в старых web parts. */
export const TreePageHeader: React.FC<Props> = ({ icon, label, title, actions }) => (
<div className={styles.header}>
<span className={styles.icon} aria-hidden="true">
<Icon name={icon} />
</span>
<div className={styles.text}>
{label && <div className={styles.label}>{label}</div>}
<div className={styles.title}>{title}</div>
</div>
{actions && <div className={styles.actions}>{actions}</div>}
</div>
);
@@ -0,0 +1,106 @@
import {
buildTree,
collectDescendantIds,
collectParentIds,
countNodes,
flattenVisible,
isDescendant,
ITree,
toggleId
} from './treeRules';
interface IItem {
id: number;
parentId?: number;
}
// 1
// ├─ 2
// │ └─ 4
// └─ 3
// 5 (родитель 99 не в выборке — становится корнем)
const ITEMS: IItem[] = [
{ id: 1 },
{ id: 2, parentId: 1 },
{ id: 3, parentId: 1 },
{ id: 4, parentId: 2 },
{ id: 5, parentId: 99 }
];
const build = (items: IItem[] = ITEMS): ITree<IItem, number> =>
buildTree(
items,
(item) => item.id,
(item) => item.parentId
);
describe('buildTree', () => {
it('корни — узлы без родителя и с родителем не из выборки', () => {
expect(build().roots.map((node) => node.id)).toEqual([1, 5]);
});
it('уровни — глубина от корня', () => {
const { byId } = build();
expect([1, 2, 3, 4, 5].map((id) => byId.get(id)?.level)).toEqual([0, 1, 1, 2, 0]);
});
it('дети в порядке исходной выборки', () => {
expect(build().byId.get(1)?.children.map((node) => node.id)).toEqual([2, 3]);
});
it('порядок в выборке не важен: ребёнок раньше родителя', () => {
const tree = build([{ id: 4, parentId: 2 }, { id: 2 }]);
expect(tree.roots.map((node) => node.id)).toEqual([2]);
expect(tree.byId.get(4)?.level).toEqual(1);
});
});
describe('isDescendant / collectDescendantIds', () => {
it('внук — потомок, сосед и сам узел — нет', () => {
const { byId } = build();
expect(isDescendant(byId, 1, 4)).toBe(true);
expect(isDescendant(byId, 2, 3)).toBe(false);
expect(isDescendant(byId, 2, 2)).toBe(false);
expect(isDescendant(byId, 404, 1)).toBe(false);
});
it('все потомки без самого узла', () => {
expect(Array.from(collectDescendantIds(build().byId, 1)).sort()).toEqual([2, 3, 4]);
expect(collectDescendantIds(build().byId, 4).size).toEqual(0);
});
});
describe('countNodes / collectParentIds', () => {
it('считает все узлы рекурсивно', () => {
expect(countNodes(build().roots)).toEqual(5);
expect(countNodes([])).toEqual(0);
});
it('родители — только узлы с детьми', () => {
expect(collectParentIds(build().roots)).toEqual([1, 2]);
});
});
describe('flattenVisible', () => {
it('всё свёрнуто — только корни', () => {
expect(flattenVisible(build().roots, new Set<number>()).map((node) => node.id)).toEqual([1, 5]);
});
it('раскрытый родитель показывает детей, свёрнутый внук — нет', () => {
expect(flattenVisible(build().roots, new Set([1])).map((node) => node.id)).toEqual([1, 2, 3, 5]);
expect(flattenVisible(build().roots, new Set([1, 2])).map((node) => node.id)).toEqual([1, 2, 4, 3, 5]);
});
it('раскрытие без раскрытого предка ничего не показывает', () => {
expect(flattenVisible(build().roots, new Set([2])).map((node) => node.id)).toEqual([1, 5]);
});
});
describe('toggleId', () => {
it('возвращает новое множество, исходное не меняется', () => {
const ids = new Set([1]);
expect(Array.from(toggleId(ids, 2))).toEqual([1, 2]);
expect(Array.from(toggleId(ids, 1))).toEqual([]);
expect(Array.from(ids)).toEqual([1]);
});
});
@@ -0,0 +1,115 @@
// Общие правила дерева для страниц «Объекты» и «Работы».
// Логика 1:1 с _buildTree / _isDescendant / _countAll / _flattenTree старых web parts:
// узел без родителя или с родителем не из выборки — корень; уровень — глубина от корня.
export interface ITreeNode<T, K> {
id: K;
item: T;
children: Array<ITreeNode<T, K>>;
/** 0 — корень. */
level: number;
}
export interface ITree<T, K> {
roots: Array<ITreeNode<T, K>>;
byId: Map<K, ITreeNode<T, K>>;
}
export function buildTree<T, K>(
items: T[],
getId: (item: T) => K,
getParentId: (item: T) => K | undefined
): ITree<T, K> {
const byId = new Map<K, ITreeNode<T, K>>();
items.forEach((item) => byId.set(getId(item), { id: getId(item), item, children: [], level: 0 }));
const roots: Array<ITreeNode<T, K>> = [];
items.forEach((item) => {
const node = byId.get(getId(item));
const parentId = getParentId(item);
const parent = parentId === undefined ? undefined : byId.get(parentId);
if (!node) return;
if (parent) {
parent.children.push(node);
} else {
roots.push(node);
}
});
assignLevels(roots, 0);
return { roots, byId };
}
function assignLevels<T, K>(nodes: Array<ITreeNode<T, K>>, level: number): void {
nodes.forEach((node) => {
node.level = level;
assignLevels(node.children, level + 1);
});
}
/** Является ли candidateId потомком nodeId (нельзя перенести узел в себя или своих потомков). */
export function isDescendant<T, K>(byId: Map<K, ITreeNode<T, K>>, nodeId: K, candidateId: K): boolean {
const node = byId.get(nodeId);
if (!node) return false;
return node.children.some((child) => child.id === candidateId || isDescendant(byId, child.id, candidateId));
}
/** Id всех потомков узла (без самого узла). */
export function collectDescendantIds<T, K>(byId: Map<K, ITreeNode<T, K>>, nodeId: K): Set<K> {
const ids = new Set<K>();
const collect = (id: K): void => {
const node = byId.get(id);
if (!node) return;
node.children.forEach((child) => {
ids.add(child.id);
collect(child.id);
});
};
collect(nodeId);
return ids;
}
/** Количество узлов дерева (рекурсивно). */
export function countNodes<T, K>(nodes: Array<ITreeNode<T, K>>): number {
return nodes.reduce((sum, node) => sum + 1 + countNodes(node.children), 0);
}
/** Id всех узлов, у которых есть дети, — для «Развернуть всё». */
export function collectParentIds<T, K>(nodes: Array<ITreeNode<T, K>>): K[] {
const ids: K[] = [];
const traverse = (list: Array<ITreeNode<T, K>>): void => {
list.forEach((node) => {
if (node.children.length === 0) return;
ids.push(node.id);
traverse(node.children);
});
};
traverse(nodes);
return ids;
}
/** Плоский список видимых узлов: дети показываются только у раскрытых родителей. */
export function flattenVisible<T, K>(nodes: Array<ITreeNode<T, K>>, expandedIds: Set<K>): Array<ITreeNode<T, K>> {
const result: Array<ITreeNode<T, K>> = [];
const traverse = (list: Array<ITreeNode<T, K>>): void => {
list.forEach((node) => {
result.push(node);
if (node.children.length > 0 && expandedIds.has(node.id)) {
traverse(node.children);
}
});
};
traverse(nodes);
return result;
}
/** Новое множество с переключённым id — state не мутируется. */
export function toggleId<K>(ids: Set<K>, id: K): Set<K> {
const next = new Set(ids);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
}
@@ -0,0 +1,32 @@
import * as React from 'react';
import { DropPosition } from './objectsTypes';
import { IObjectDrag } from './useObjectDrag';
import styles from './ObjectsPage.module.scss';
interface Props {
targetId: number;
position: DropPosition;
drag: IObjectDrag;
}
const POSITION_CLASS: Record<DropPosition, string> = {
before: styles.dropZoneEdge,
inside: styles.dropZoneInside,
after: styles.dropZoneEdge
};
/** Тонкая полоса до / внутри / после узла, куда можно бросить перетаскиваемый объект. */
export const DropZone: React.FC<Props> = ({ targetId, position, drag }) => {
const isActive = drag.activeZone?.targetId === targetId && drag.activeZone.position === position;
const className = [styles.dropZone, POSITION_CLASS[position], isActive ? styles.dropZoneActive : '']
.filter(Boolean)
.join(' ');
return (
<div
className={className}
onDragOver={(event) => drag.onZoneDragOver({ targetId, position }, event)}
onDragLeave={drag.onZoneDragLeave}
/>
);
};
@@ -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<void>;
onClose: () => void;
}
/** Модалка добавления / правки объекта. Монтируется заново при каждом открытии. */
export const ObjectModal: React.FC<Props> = ({ target, tree, projectTitle, onSubmit, onClose }) => {
const { mode, node } = target;
const [title, setTitle] = React.useState<string>(mode === 'edit' && node ? node.item.title : '');
const [parentValue, setParentValue] = React.useState<string>(node?.item.parentId ? String(node.item.parentId) : '');
const [isTitleMissing, setIsTitleMissing] = React.useState<boolean>(false);
const [isSaving, setIsSaving] = React.useState<boolean>(false);
const [saveError, setSaveError] = React.useState<string | undefined>(undefined);
const titleRef = React.useRef<HTMLInputElement>(null);
const onTitleChange = (value: string): void => {
setTitle(value);
setIsTitleMissing(false);
};
const submit = async (): Promise<void> => {
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 = (
<>
<Button variant="secondary" onClick={onClose} disabled={isSaving}>
{TEXTS.cancel}
</Button>
<Button variant="primary" onClick={onSave} disabled={isSaving}>
{getSubmitLabel(target)}
{isSaving && <Spinner size="xSmall" />}
</Button>
</>
);
return (
<Dialog open title={getModalTitle(target)} onDismiss={onClose} footer={footer} width="sm">
<div className={styles.modalBody}>
{saveError && <Alert variant="err">{saveError}</Alert>}
<FormField label={TEXTS.titleLabel} htmlFor={TITLE_INPUT_ID} required>
<FormInput
id={TITLE_INPUT_ID}
ref={titleRef}
value={title}
onChange={onTitleChange}
placeholder={TEXTS.titlePlaceholder}
isInvalid={isTitleMissing}
selectOnFocus
autoFocus
onEnter={onSave}
/>
{isTitleMissing && <span className={styles.fieldError}>{TEXTS.titleRequired}</span>}
</FormField>
{mode !== 'addRoot' && node && (
<FormField label={TEXTS.parentLabel}>
{isParentSelectable(target) ? (
<Select value={parentValue} onChange={setParentValue} options={getParentOptions(node, tree)} />
) : (
<TextInput value={getParentDisplay(target, tree)} onChange={() => undefined} readOnly />
)}
</FormField>
)}
<FormField label={TEXTS.projectLabel}>
<TextInput value={projectTitle} onChange={() => undefined} readOnly />
</FormField>
</div>
</Dialog>
);
};
@@ -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<Props> = ({ 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<HTMLButtonElement>): 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 (
<div>
<DropZone targetId={node.id} position="before" drag={drag} />
<div
className={rowClassName(hasChildren, view.selectedId === node.id, drag.draggingId === node.id)}
style={{ paddingLeft: ROW_BASE_INDENT_PX + node.level * ROW_LEVEL_INDENT_PX }}
draggable={canEdit}
onDragStart={(event) => drag.onDragStart(node.id, event)}
onDragEnd={drag.onDragEnd}
onClick={() => view.clickRow(node)}
onContextMenu={onContextMenu}
>
<span className={styles.nodeToggle} onClick={onToggleClick}>
{hasChildren ? (
<span className={isExpanded ? styles.chevronExpanded : styles.chevron}>
<Icon name={isExpanded ? 'chevron-down' : 'chevron-right'} size="sm" />
</span>
) : (
<span className={styles.leafDot} />
)}
</span>
<span className={hasChildren ? styles.iconFolder : styles.iconFile} aria-hidden="true">
<Icon name={hasChildren ? 'folder' : 'document'} size="sm" />
</span>
<span className={styles.nodeTitle}>{node.item.title}</span>
{hasChildren && <span className={styles.nodeBadge}>{node.children.length}</span>}
{canEdit && (
<button
type="button"
className={styles.nodeMenuButton}
title={OBJECTS_TEXTS.nodeMenuTooltip}
onClick={onMenuClick}
>
<Icon name="more" size="sm" />
</button>
)}
</div>
<DropZone targetId={node.id} position="inside" drag={drag} />
{hasChildren && isExpanded && (
<div>
{node.children.map((child) => (
<ObjectTreeNode
key={child.id}
node={child}
canEdit={canEdit}
view={view}
drag={drag}
onOpenMenu={onOpenMenu}
/>
))}
</div>
)}
<DropZone targetId={node.id} position="after" drag={drag} />
</div>
);
};
@@ -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<Props> = ({ title, canEdit, isExporting, onAddRoot, onExport }) => (
<TreePageHeader
icon="folder"
label={TEXTS.label}
title={title}
actions={
<>
{canEdit && (
<Button variant="secondary" size="sm" title={TEXTS.addRootTooltip} onClick={onAddRoot}>
<Icon name="plus" size="sm" /> {TEXTS.addRoot}
</Button>
)}
<Button variant="secondary" size="sm" title={TEXTS.exportTooltip} onClick={onExport} disabled={isExporting}>
{isExporting ? (
<Spinner size="xSmall" label={TEXTS.exporting} />
) : (
<>
<Icon name="download" size="sm" /> {TEXTS.export}
</>
)}
</Button>
</>
}
/>
);
@@ -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;
}
@@ -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 <ObjectsPageContent key={route.projectId ?? ''} projectId={route.projectId} />;
};
@@ -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<Props> = ({ 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<IOpenMenu | undefined>(undefined);
const [modalTarget, setModalTarget] = React.useState<IObjectModalTarget | undefined>(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<void> => {
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 (
<div className={styles.page}>
<ObjectsHeader
title={headerTitle}
canEdit={data.canEdit}
isExporting={exporter.isExporting}
onAddRoot={openAddRoot}
onExport={() => {
exporter.exportExcel().catch(() => undefined);
}}
/>
{exporter.exportError && <Alert variant="err">{exporter.exportError}</Alert>}
{actions.actionError && <Alert variant="err">{actions.actionError}</Alert>}
<div className={styles.card}>
{data.status === 'loading' && <Spinner block label={OBJECTS_TEXTS.loading} className={styles.loader} />}
{data.status === 'error' && (
<div className={styles.errorBlock}>
<Alert variant="err">{data.errorMessage}</Alert>
</div>
)}
{data.status === 'ready' && (
<ObjectsTree
tree={data.tree}
canEdit={data.canEdit}
view={view}
drag={drag}
onOpenMenu={(node, anchor) => setMenu({ node, anchor })}
onAddRoot={openAddRoot}
/>
)}
</div>
{menu && (
<ContextMenu
anchor={menu.anchor}
items={buildObjectMenuItems(menu.node)}
onSelect={(key) => onMenuSelect(menu.node, key)}
onClose={() => setMenu(undefined)}
/>
)}
{modalTarget && (
<ObjectModal
target={modalTarget}
tree={data.tree}
projectTitle={data.projectTitle ?? ''}
onSubmit={onModalSubmit}
onClose={() => setModalTarget(undefined)}
/>
)}
</div>
);
};
@@ -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<Props> = ({ tree, canEdit, view, drag, onOpenMenu, onAddRoot }) => (
<>
<div className={styles.toolbar}>
<span className={styles.totalCount}>{OBJECTS_TEXTS.toolbar.total(countNodes(tree.roots))}</span>
<div className={styles.toolbarActions}>
<Button variant="secondary" size="sm" onClick={view.expandAll}>
{OBJECTS_TEXTS.toolbar.expandAll}
</Button>
<Button variant="secondary" size="sm" onClick={view.collapseAll}>
{OBJECTS_TEXTS.toolbar.collapseAll}
</Button>
</div>
</div>
{tree.roots.length === 0 && (
<EmptyState
icon="folder"
title={OBJECTS_TEXTS.empty}
action={
canEdit && (
<Button variant="primary" onClick={onAddRoot}>
{OBJECTS_TEXTS.emptyAdd}
</Button>
)
}
/>
)}
<div className={styles.tree} onDrop={drag.onDrop}>
{tree.roots.map((node) => (
<ObjectTreeNode
key={node.id}
node={node}
canEdit={canEdit}
view={view}
drag={drag}
onOpenMenu={onOpenMenu}
/>
))}
</div>
</>
);
@@ -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<IContextMenuItem<ObjectMenuAction>> {
const items: Array<IContextMenuItem<ObjectMenuAction>> = [];
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;
}
@@ -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');
});
});
@@ -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<string, string> = {
Accept: 'application/json;odata=verbose',
'Content-Type': 'application/json;odata=verbose',
'odata-version': ''
};
const MERGE_HEADERS: Record<string, string> = {
...VERBOSE_HEADERS,
'IF-MATCH': '*',
'X-HTTP-Method': 'MERGE'
};
const DELETE_HEADERS: Record<string, string> = {
'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<string> {
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<string> {
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<IProjectObject[]> {
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<ISpCollection<IObjectRow>>;
return (data.value ?? []).map(toProjectObject);
}
/** Право редактирования — первая запись «Доступы» проекта и пользователя с edit0. Любая ошибка — нет права. */
async function fetchCanEdit(deps: IObjectsRepositoryDeps, projectId: string): Promise<boolean> {
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<string | undefined> {
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<string, string>,
body: Record<string, unknown>
): Promise<void> {
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<void> {
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<Blob> {
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<string>;
fetchObjects(projectTitle: string): Promise<IProjectObject[]>;
fetchCanEdit(projectId: string): Promise<boolean>;
createObject(projectTitle: string, title: string, parentId: number | undefined): Promise<void>;
/** newParentId — только при смене родителя Раздела; undefined — поле не трогаем. */
updateObject(itemId: number, title: string, newParentId: number | undefined): Promise<void>;
/** Перенос (drag & drop): меняется только родитель; undefined — в корень. */
moveObject(itemId: number, newParentId: number | undefined): Promise<void>;
deleteObject(itemId: number): Promise<void>;
downloadObjectsExcel(projectId: string): Promise<Blob>;
}
/**
* Репозиторий хранит кэш типа элементов списка (ListItemEntityTypeFullName) —
* как старый web part: тип запрашивается один раз, перед первой записью.
*/
export function createObjectsRepository(deps: IObjectsRepositoryDeps): IObjectsRepository {
let entityTypePromise: Promise<string | undefined> | undefined;
// Неудачный запрос не кэшируется — следующая запись спросит тип снова, как в старом коде.
const getEntityType = (): Promise<string | undefined> => {
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<string, unknown> = {
...(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<string, unknown> = { ...(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)
};
}
@@ -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('текст');
});
});
@@ -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);
}
@@ -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<IProjectObject, number>;
export type ObjectTree = ITree<IProjectObject, number>;
/**
* Режим модалки: 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 } };
}
@@ -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;
@@ -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 };
}
@@ -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<number | undefined>(undefined);
const [activeZone, setActiveZone] = React.useState<IDropZone | undefined>(undefined);
// Последняя разрешённая зона: в старом коде dragleave снимал только подсветку, а цель запоминалась.
const dropTargetRef = React.useRef<IDropZone | undefined>(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
};
}
@@ -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<number>;
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<Set<number>>(() => new Set<number>());
const [selectedId, setSelectedId] = React.useState<number | undefined>(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 };
}
@@ -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<IObjectsState>(INITIAL_STATE);
const [reloadKey, setReloadKey] = React.useState<number>(0);
React.useEffect(() => {
let isCancelled = false;
const update = (patch: Partial<IObjectsState>): void => {
if (!isCancelled) setState((prev) => ({ ...prev, ...patch }));
};
const load = async (): Promise<void> => {
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 };
}
@@ -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<void>;
}
/** Кнопка «Экспорт»: Excel со всеми объектами проекта из корпоративного API — `objects-{projectId}.xlsx`. */
export function useObjectsExport(repository: IObjectsRepository, projectId: string | undefined): IObjectsExport {
const [isExporting, setIsExporting] = React.useState<boolean>(false);
const [exportError, setExportError] = React.useState<string | undefined>(undefined);
const exportExcel = async (): Promise<void> => {
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 };
}