From e12fce6032a8df865a283ddb5cb9f3b9ed7fa2cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A2=D0=B8=D0=BC=D1=83=D1=80=20=D0=93=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=B4=D0=B5=D1=86=D0=BA=D0=B8=D0=B9?= Date: Fri, 16 Oct 2026 09:48:04 +0500 Subject: [PATCH] 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) --- docs/MIGRATION-CHECKLIST.md | 9 +- src/webparts/stroyBot/app/PageOutlet.tsx | 3 + .../pages/_tree/ContextMenu.module.scss | 55 +++++ .../stroyBot/pages/_tree/ContextMenu.tsx | 87 +++++++ .../pages/_tree/FormInput.module.scss | 30 +++ .../stroyBot/pages/_tree/FormInput.tsx | 52 ++++ .../pages/_tree/TreePageHeader.module.scss | 60 +++++ .../stroyBot/pages/_tree/TreePageHeader.tsx | 26 ++ .../stroyBot/pages/_tree/treeRules.test.ts | 106 ++++++++ .../stroyBot/pages/_tree/treeRules.ts | 115 +++++++++ .../stroyBot/pages/objects/DropZone.tsx | 32 +++ .../stroyBot/pages/objects/ObjectModal.tsx | 110 +++++++++ .../stroyBot/pages/objects/ObjectTreeNode.tsx | 114 +++++++++ .../stroyBot/pages/objects/ObjectsHeader.tsx | 41 ++++ .../pages/objects/ObjectsPage.module.scss | 222 +++++++++++++++++ .../stroyBot/pages/objects/ObjectsPage.tsx | 12 + .../pages/objects/ObjectsPageContent.tsx | 135 +++++++++++ .../stroyBot/pages/objects/ObjectsTree.tsx | 64 +++++ .../stroyBot/pages/objects/objectMenu.ts | 22 ++ .../pages/objects/objectsRepository.test.ts | 227 ++++++++++++++++++ .../pages/objects/objectsRepository.ts | 222 +++++++++++++++++ .../pages/objects/objectsRules.test.ts | 143 +++++++++++ .../stroyBot/pages/objects/objectsRules.ts | 112 +++++++++ .../stroyBot/pages/objects/objectsTypes.ts | 41 ++++ src/webparts/stroyBot/pages/objects/texts.ts | 64 +++++ .../pages/objects/useObjectActions.ts | 71 ++++++ .../stroyBot/pages/objects/useObjectDrag.ts | 72 ++++++ .../pages/objects/useObjectTreeView.ts | 49 ++++ .../stroyBot/pages/objects/useObjectsData.ts | 71 ++++++ .../pages/objects/useObjectsExport.ts | 34 +++ 30 files changed, 2399 insertions(+), 2 deletions(-) create mode 100644 src/webparts/stroyBot/pages/_tree/ContextMenu.module.scss create mode 100644 src/webparts/stroyBot/pages/_tree/ContextMenu.tsx create mode 100644 src/webparts/stroyBot/pages/_tree/FormInput.module.scss create mode 100644 src/webparts/stroyBot/pages/_tree/FormInput.tsx create mode 100644 src/webparts/stroyBot/pages/_tree/TreePageHeader.module.scss create mode 100644 src/webparts/stroyBot/pages/_tree/TreePageHeader.tsx create mode 100644 src/webparts/stroyBot/pages/_tree/treeRules.test.ts create mode 100644 src/webparts/stroyBot/pages/_tree/treeRules.ts create mode 100644 src/webparts/stroyBot/pages/objects/DropZone.tsx create mode 100644 src/webparts/stroyBot/pages/objects/ObjectModal.tsx create mode 100644 src/webparts/stroyBot/pages/objects/ObjectTreeNode.tsx create mode 100644 src/webparts/stroyBot/pages/objects/ObjectsHeader.tsx create mode 100644 src/webparts/stroyBot/pages/objects/ObjectsPage.module.scss create mode 100644 src/webparts/stroyBot/pages/objects/ObjectsPage.tsx create mode 100644 src/webparts/stroyBot/pages/objects/ObjectsPageContent.tsx create mode 100644 src/webparts/stroyBot/pages/objects/ObjectsTree.tsx create mode 100644 src/webparts/stroyBot/pages/objects/objectMenu.ts create mode 100644 src/webparts/stroyBot/pages/objects/objectsRepository.test.ts create mode 100644 src/webparts/stroyBot/pages/objects/objectsRepository.ts create mode 100644 src/webparts/stroyBot/pages/objects/objectsRules.test.ts create mode 100644 src/webparts/stroyBot/pages/objects/objectsRules.ts create mode 100644 src/webparts/stroyBot/pages/objects/objectsTypes.ts create mode 100644 src/webparts/stroyBot/pages/objects/texts.ts create mode 100644 src/webparts/stroyBot/pages/objects/useObjectActions.ts create mode 100644 src/webparts/stroyBot/pages/objects/useObjectDrag.ts create mode 100644 src/webparts/stroyBot/pages/objects/useObjectTreeView.ts create mode 100644 src/webparts/stroyBot/pages/objects/useObjectsData.ts create mode 100644 src/webparts/stroyBot/pages/objects/useObjectsExport.ts diff --git a/docs/MIGRATION-CHECKLIST.md b/docs/MIGRATION-CHECKLIST.md index 1eb271e..d1d9ebe 100644 --- a/docs/MIGRATION-CHECKLIST.md +++ b/docs/MIGRATION-CHECKLIST.md @@ -83,7 +83,7 @@ - [ ] Пусто — «Объекты не найдены» + «+ Добавить объект» (если `canEdit`) - [ ] Тулбар «{N} объектов» (рекурсивно), «Развернуть», «Свернуть» - [ ] Дерево: отступ, иконка папки/файла, счётчик детей, шеврон, клик по строке — выделить + раскрыть -- [ ] Раскрытые узлы сохраняются между перезагрузками в рамках сессии +- [ ] Раскрытые и выделенный узлы сохраняются при перезагрузке дерева после операций (в памяти страницы, как в старом коде; при обновлении страницы браузера — сбрасываются) - [ ] Уровни: 0 — Объект-пятно, 1 — Раздел, 2 — Вид работ (не глубже 3 уровней) - [ ] Контекстное меню (⋮ и правый клик, только `canEdit`): Добавить объект (не на уровне 2), Редактировать, Работы, Удалить - [ ] Модалка: «Название *» (обязательно, Enter — сохранить), «Родительский объект» (select только при правке Раздела: узлы уровня 0, сортировка ru, без себя и потомков), «Проект» (read-only) @@ -95,7 +95,12 @@ - [ ] Список «Объекты»: `Title`, `project` (текст = название проекта), `parent` (self-lookup) - [ ] Права: «Доступы» `projectId`/`userId` → `edit0` -🐞 Перетаскивание меняет только родителя, порядок не сохраняется. 🐞 Удаление узла не удаляет детей. +🐞 Перетаскивание меняет только родителя, порядок не сохраняется. 🐞 Удаление узла не удаляет детей. 🐞 Зона сброса «внутрь» нулевой высоты — на практике узел можно бросить только «до» / «после» (перенесено как есть). + +**Сознательные отличия от objects:** +- `alert()` → сообщение `Alert` на странице (ошибки переноса, удаления, экспорта) или в модалке (ошибка сохранения). Тексты те же. +- `confirm()` удаления → `confirmModal` с кнопкой «Удалить»; текст тот же, но переносы строк ui-kit показывает одним абзацем. +- Адрес сайта — настройка «URL сайта SharePoint» (раньше `pageContext.web.absoluteUrl`), экспорт — через клиент корпоративного API и настройку «Базовый URL API» (по умолчанию тот же `https://api.tnsu.kz`). --- diff --git a/src/webparts/stroyBot/app/PageOutlet.tsx b/src/webparts/stroyBot/app/PageOutlet.tsx index d798a20..75d2895 100644 --- a/src/webparts/stroyBot/app/PageOutlet.tsx +++ b/src/webparts/stroyBot/app/PageOutlet.tsx @@ -6,6 +6,7 @@ import { ResourcesPage } from '../pages/resources/ResourcesPage'; import { BinderPage } from '../pages/binder/BinderPage'; import { ReportsPage } from '../pages/reports/ReportsPage'; import { ImportWorksPage } from '../pages/import/ImportWorksPage'; +import { ObjectsPage } from '../pages/objects/ObjectsPage'; /** Показывает страницу текущего раздела. Разделы подключаются сюда по мере переноса. */ export const PageOutlet: React.FC = () => { @@ -22,6 +23,8 @@ export const PageOutlet: React.FC = () => { return ; case 'import': return ; + case 'objects': + return ; default: return ; } diff --git a/src/webparts/stroyBot/pages/_tree/ContextMenu.module.scss b/src/webparts/stroyBot/pages/_tree/ContextMenu.module.scss new file mode 100644 index 0000000..8257dfd --- /dev/null +++ b/src/webparts/stroyBot/pages/_tree/ContextMenu.module.scss @@ -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; +} diff --git a/src/webparts/stroyBot/pages/_tree/ContextMenu.tsx b/src/webparts/stroyBot/pages/_tree/ContextMenu.tsx new file mode 100644 index 0000000..6a8fb0f --- /dev/null +++ b/src/webparts/stroyBot/pages/_tree/ContextMenu.tsx @@ -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 { + 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 { + anchor: IMenuAnchor; + items: Array>; + 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({ anchor, items, onSelect, onClose }: Props): React.ReactElement { + const menuRef = React.useRef(null); + const [position, setPosition] = React.useState(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( +
+ {items.map((item) => ( + + {item.dividerBefore &&
} + + + ))} +
, + document.body + ); +} diff --git a/src/webparts/stroyBot/pages/_tree/FormInput.module.scss b/src/webparts/stroyBot/pages/_tree/FormInput.module.scss new file mode 100644 index 0000000..5d9b70d --- /dev/null +++ b/src/webparts/stroyBot/pages/_tree/FormInput.module.scss @@ -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); +} diff --git a/src/webparts/stroyBot/pages/_tree/FormInput.tsx b/src/webparts/stroyBot/pages/_tree/FormInput.tsx new file mode 100644 index 0000000..e16af34 --- /dev/null +++ b/src/webparts/stroyBot/pages/_tree/FormInput.tsx @@ -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( + ({ id, type = 'text', value, onChange, placeholder, step, isInvalid, selectOnFocus, autoFocus, onEnter }, ref) => { + const onKeyDown = (event: React.KeyboardEvent): void => { + if (event.key === 'Enter' && onEnter) { + event.preventDefault(); + onEnter(); + } + }; + + return ( + event.target.select() : undefined} + onChange={(event) => onChange(event.target.value)} + onKeyDown={onKeyDown} + /> + ); + } +); + +FormInput.displayName = 'FormInput'; diff --git a/src/webparts/stroyBot/pages/_tree/TreePageHeader.module.scss b/src/webparts/stroyBot/pages/_tree/TreePageHeader.module.scss new file mode 100644 index 0000000..a0534e4 --- /dev/null +++ b/src/webparts/stroyBot/pages/_tree/TreePageHeader.module.scss @@ -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; +} diff --git a/src/webparts/stroyBot/pages/_tree/TreePageHeader.tsx b/src/webparts/stroyBot/pages/_tree/TreePageHeader.tsx new file mode 100644 index 0000000..ed8ffc2 --- /dev/null +++ b/src/webparts/stroyBot/pages/_tree/TreePageHeader.tsx @@ -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 = ({ icon, label, title, actions }) => ( +
+ +
+ {label &&
{label}
} +
{title}
+
+ {actions &&
{actions}
} +
+); diff --git a/src/webparts/stroyBot/pages/_tree/treeRules.test.ts b/src/webparts/stroyBot/pages/_tree/treeRules.test.ts new file mode 100644 index 0000000..40a3796 --- /dev/null +++ b/src/webparts/stroyBot/pages/_tree/treeRules.test.ts @@ -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 => + 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()).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]); + }); +}); diff --git a/src/webparts/stroyBot/pages/_tree/treeRules.ts b/src/webparts/stroyBot/pages/_tree/treeRules.ts new file mode 100644 index 0000000..381c251 --- /dev/null +++ b/src/webparts/stroyBot/pages/_tree/treeRules.ts @@ -0,0 +1,115 @@ +// Общие правила дерева для страниц «Объекты» и «Работы». +// Логика 1:1 с _buildTree / _isDescendant / _countAll / _flattenTree старых web parts: +// узел без родителя или с родителем не из выборки — корень; уровень — глубина от корня. + +export interface ITreeNode { + id: K; + item: T; + children: Array>; + /** 0 — корень. */ + level: number; +} + +export interface ITree { + roots: Array>; + byId: Map>; +} + +export function buildTree( + items: T[], + getId: (item: T) => K, + getParentId: (item: T) => K | undefined +): ITree { + const byId = new Map>(); + items.forEach((item) => byId.set(getId(item), { id: getId(item), item, children: [], level: 0 })); + + const roots: Array> = []; + 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(nodes: Array>, level: number): void { + nodes.forEach((node) => { + node.level = level; + assignLevels(node.children, level + 1); + }); +} + +/** Является ли candidateId потомком nodeId (нельзя перенести узел в себя или своих потомков). */ +export function isDescendant(byId: Map>, 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(byId: Map>, nodeId: K): Set { + const ids = new Set(); + 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(nodes: Array>): number { + return nodes.reduce((sum, node) => sum + 1 + countNodes(node.children), 0); +} + +/** Id всех узлов, у которых есть дети, — для «Развернуть всё». */ +export function collectParentIds(nodes: Array>): K[] { + const ids: K[] = []; + const traverse = (list: Array>): void => { + list.forEach((node) => { + if (node.children.length === 0) return; + ids.push(node.id); + traverse(node.children); + }); + }; + traverse(nodes); + return ids; +} + +/** Плоский список видимых узлов: дети показываются только у раскрытых родителей. */ +export function flattenVisible(nodes: Array>, expandedIds: Set): Array> { + const result: Array> = []; + const traverse = (list: Array>): 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(ids: Set, id: K): Set { + const next = new Set(ids); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; +} diff --git a/src/webparts/stroyBot/pages/objects/DropZone.tsx b/src/webparts/stroyBot/pages/objects/DropZone.tsx new file mode 100644 index 0000000..416385d --- /dev/null +++ b/src/webparts/stroyBot/pages/objects/DropZone.tsx @@ -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 = { + before: styles.dropZoneEdge, + inside: styles.dropZoneInside, + after: styles.dropZoneEdge +}; + +/** Тонкая полоса до / внутри / после узла, куда можно бросить перетаскиваемый объект. */ +export const DropZone: React.FC = ({ 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 ( +
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 ( + +
+ {saveError && {saveError}} + + + {isTitleMissing && {TEXTS.titleRequired}} + + + {mode !== 'addRoot' && node && ( + + {isParentSelectable(target) ? ( +