From 673ace2ba61b2112002c3837776feb0f22e8d9e2 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:55:54 +0500 Subject: [PATCH] feat: port stroy-bot-works as works page Tree table of an object's works from Dataverse with add / add child / edit / delete, "save and add more", units dictionary and parent re-binding, 1:1 with the legacy web part. Reuses the tree widgets from the objects page; the parent navigation property lookup is cached as a single promise so concurrent saves don't race. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/MIGRATION-CHECKLIST.md | 9 +- src/webparts/stroyBot/app/PageOutlet.tsx | 3 + .../stroyBot/pages/works/WorkModal.tsx | 102 ++++++ .../stroyBot/pages/works/WorkTitleCell.tsx | 41 +++ .../pages/works/WorksPage.module.scss | 97 ++++++ .../stroyBot/pages/works/WorksPage.tsx | 12 + .../stroyBot/pages/works/WorksPageContent.tsx | 127 +++++++ .../stroyBot/pages/works/WorksTable.tsx | 66 ++++ src/webparts/stroyBot/pages/works/texts.ts | 58 ++++ .../stroyBot/pages/works/useWorkEditor.ts | 88 +++++ .../stroyBot/pages/works/useWorksData.ts | 82 +++++ .../pages/works/worksRepository.test.ts | 209 ++++++++++++ .../stroyBot/pages/works/worksRepository.ts | 312 ++++++++++++++++++ .../stroyBot/pages/works/worksRules.test.ts | 95 ++++++ .../stroyBot/pages/works/worksRules.ts | 94 ++++++ .../stroyBot/pages/works/worksTypes.ts | 50 +++ 16 files changed, 1444 insertions(+), 1 deletion(-) create mode 100644 src/webparts/stroyBot/pages/works/WorkModal.tsx create mode 100644 src/webparts/stroyBot/pages/works/WorkTitleCell.tsx create mode 100644 src/webparts/stroyBot/pages/works/WorksPage.module.scss create mode 100644 src/webparts/stroyBot/pages/works/WorksPage.tsx create mode 100644 src/webparts/stroyBot/pages/works/WorksPageContent.tsx create mode 100644 src/webparts/stroyBot/pages/works/WorksTable.tsx create mode 100644 src/webparts/stroyBot/pages/works/texts.ts create mode 100644 src/webparts/stroyBot/pages/works/useWorkEditor.ts create mode 100644 src/webparts/stroyBot/pages/works/useWorksData.ts create mode 100644 src/webparts/stroyBot/pages/works/worksRepository.test.ts create mode 100644 src/webparts/stroyBot/pages/works/worksRepository.ts create mode 100644 src/webparts/stroyBot/pages/works/worksRules.test.ts create mode 100644 src/webparts/stroyBot/pages/works/worksRules.ts create mode 100644 src/webparts/stroyBot/pages/works/worksTypes.ts diff --git a/docs/MIGRATION-CHECKLIST.md b/docs/MIGRATION-CHECKLIST.md index d1d9ebe..89c8a23 100644 --- a/docs/MIGRATION-CHECKLIST.md +++ b/docs/MIGRATION-CHECKLIST.md @@ -119,7 +119,14 @@ - [ ] Навигационное свойство родителя определяется через `EntityDefinitions` (fallback `cree9_parentid`) - [ ] Проект находится подъёмом по родителям объекта до непустого `project` -🐞 Путь объекта (`#headerTitle`) не выводится. 🐞 Нет проверки «начало ≤ окончание». +🐞 Путь объекта (`#headerTitle`) не выводится. 🐞 Нет проверки «начало ≤ окончание». 🐞 Отступы в списке «Родительская работа» — обычные пробелы, браузер их схлопывает (как было). + +**Сознательные отличия от works:** +- `alert()` → сообщение `Alert`: ошибка сохранения — в модалке, ошибки удаления — над таблицей. Тексты те же. +- Своё окно подтверждения удаления → `confirmModal` из ui-kit с тем же заголовком, текстом и кнопками. +- PATCH и DELETE идут через тот же AAD-клиент Dataverse, а не через `fetch` с токеном вручную; заголовки те же. +- Адрес сайта — настройка «URL сайта SharePoint» (раньше `pageContext.web.absoluteUrl`), Dataverse — настройка «URL Dataverse»; имя таблицы — константа (раньше настройка `worksTable`). +- Таблица — `DataTable` из ui-kit; меню ⋮ — общее меню из `pages/_tree`. --- diff --git a/src/webparts/stroyBot/app/PageOutlet.tsx b/src/webparts/stroyBot/app/PageOutlet.tsx index 75d2895..0536314 100644 --- a/src/webparts/stroyBot/app/PageOutlet.tsx +++ b/src/webparts/stroyBot/app/PageOutlet.tsx @@ -7,6 +7,7 @@ import { BinderPage } from '../pages/binder/BinderPage'; import { ReportsPage } from '../pages/reports/ReportsPage'; import { ImportWorksPage } from '../pages/import/ImportWorksPage'; import { ObjectsPage } from '../pages/objects/ObjectsPage'; +import { WorksPage } from '../pages/works/WorksPage'; /** Показывает страницу текущего раздела. Разделы подключаются сюда по мере переноса. */ export const PageOutlet: React.FC = () => { @@ -25,6 +26,8 @@ export const PageOutlet: React.FC = () => { return ; case 'objects': return ; + case 'works': + return ; default: return ; } diff --git a/src/webparts/stroyBot/pages/works/WorkModal.tsx b/src/webparts/stroyBot/pages/works/WorkModal.tsx new file mode 100644 index 0000000..5c47dd9 --- /dev/null +++ b/src/webparts/stroyBot/pages/works/WorkModal.tsx @@ -0,0 +1,102 @@ +import * as React from 'react'; +import { Alert, Button, Dialog, FormField, Select, TextInput } from '@tnsu/ui-kit'; +import { ITree } from '../_tree/treeRules'; +import { FormInput } from '../_tree/FormInput'; +import { IWorkEditor } from './useWorkEditor'; +import { parentOptions, unitOptions } from './worksRules'; +import { IWork } from './worksTypes'; +import { WORKS_TEXTS } from './texts'; +import styles from './WorksPage.module.scss'; + +const TEXTS = WORKS_TEXTS.modal; + +interface Props { + editor: IWorkEditor; + tree: ITree; + units: string[]; + objectTitle: string; +} + +/** Модалка «Добавить работу» / «Редактировать работу». */ +export const WorkModal: React.FC = ({ editor, tree, units, objectTitle }) => { + const { form, mode, isSaving, setField } = editor; + const isAddMode = mode === 'add'; + const saveLabel = isAddMode ? TEXTS.add : TEXTS.save; + + const footer = ( + <> + + {isAddMode && ( + + )} + + + ); + + return ( + +
+ + setField('title', value)} + placeholder={TEXTS.titlePlaceholder} + isInvalid={editor.isTitleInvalid} + autoFocus + /> + + + setField('parentId', value)} + options={parentOptions(tree, isAddMode ? undefined : editor.editingWork?.id)} + /> + + +
{objectTitle}
+
+ + setField('start', value)} /> + + + setField('finish', value)} /> + + {editor.saveError && {editor.saveError}} +
+
+ ); +}; diff --git a/src/webparts/stroyBot/pages/works/WorkTitleCell.tsx b/src/webparts/stroyBot/pages/works/WorkTitleCell.tsx new file mode 100644 index 0000000..ee13ba4 --- /dev/null +++ b/src/webparts/stroyBot/pages/works/WorkTitleCell.tsx @@ -0,0 +1,41 @@ +import * as React from 'react'; +import { Icon } from '@tnsu/ui-kit'; +import { WorkNode } from './worksTypes'; +import { WORKS_TEXTS } from './texts'; +import styles from './WorksPage.module.scss'; + +/** Отступ на уровень вложенности — 20px, как в старом коде. */ +const INDENT_PER_LEVEL_PX = 20; + +interface Props { + node: WorkNode; + isExpanded: boolean; + onToggle: (workId: string) => void; +} + +/** Ячейка «Наименование»: отступ по уровню и шеврон у родителей. */ +export const WorkTitleCell: React.FC = ({ node, isExpanded, onToggle }) => { + const hasChildren = node.children.length > 0; + const className = hasChildren ? `${styles.titleCell} ${styles.parentTitle}` : styles.titleCell; + + return ( + + {hasChildren ? ( + + ) : ( + + )} + {node.item.title} + + ); +}; diff --git a/src/webparts/stroyBot/pages/works/WorksPage.module.scss b/src/webparts/stroyBot/pages/works/WorksPage.module.scss new file mode 100644 index 0000000..28ea421 --- /dev/null +++ b/src/webparts/stroyBot/pages/works/WorksPage.module.scss @@ -0,0 +1,97 @@ +@import '../../../../styles/design-tokens'; + +// Ширина колонки с шевроном — чтобы названия детей без шеврона выравнивались с родителями. +$toggle-size: 20px; + +.page { + display: flex; + flex-direction: column; + gap: $sp-4; + width: 100%; +} + +.container { + display: flex; + flex-direction: column; + gap: $sp-3; + background: $white; + border: 1px solid $gray-200; + border-radius: $r-lg; + box-shadow: $sh-1; + padding: $sp-4; +} + +.toolbar { + display: flex; + justify-content: flex-end; +} + +.titleCell { + display: inline-flex; + align-items: center; + gap: $sp-1; +} + +.parentTitle { + font-weight: 600; + color: $navy-900; +} + +.toggleButton { + display: inline-flex; + align-items: center; + justify-content: center; + width: $toggle-size; + height: $toggle-size; + padding: 0; + border: 0; + border-radius: $r-sm; + background: transparent; + color: $gray-500; + cursor: pointer; + + &:hover { + background: $navy-50; + color: $navy-900; + } +} + +.toggleSpacer { + display: inline-block; + width: $toggle-size; + flex-shrink: 0; +} + +.dotsButton { + display: inline-flex; + align-items: center; + justify-content: center; + padding: $sp-1; + border: 0; + border-radius: $r-sm; + background: transparent; + color: $gray-500; + cursor: pointer; + + &:hover { + background: $navy-50; + color: $navy-900; + } +} + +.modalFields { + display: flex; + flex-direction: column; + gap: $sp-3; +} + +.readonlyField { + padding: $sp-2 $sp-3; + border: 1px solid $gray-200; + border-radius: $r-md; + background: $gray-50; + color: $gray-500; + font-size: $fs-body; + // Пустой объект не схлопывает поле в полоску. + min-height: $sp-5; +} diff --git a/src/webparts/stroyBot/pages/works/WorksPage.tsx b/src/webparts/stroyBot/pages/works/WorksPage.tsx new file mode 100644 index 0000000..101f4b4 --- /dev/null +++ b/src/webparts/stroyBot/pages/works/WorksPage.tsx @@ -0,0 +1,12 @@ +import * as React from 'react'; +import { useRouter } from '../../app/routing/RouterContext'; +import { WorksPageContent } from './WorksPageContent'; + +/** + * Раздел «Работы» (бывший stroy-bot-works): работы объекта из ?objectId=. + * Смена объекта — новое состояние страницы (раскрытые строки, модалки). + */ +export const WorksPage: React.FC = () => { + const { route } = useRouter(); + return ; +}; diff --git a/src/webparts/stroyBot/pages/works/WorksPageContent.tsx b/src/webparts/stroyBot/pages/works/WorksPageContent.tsx new file mode 100644 index 0000000..fbd82e5 --- /dev/null +++ b/src/webparts/stroyBot/pages/works/WorksPageContent.tsx @@ -0,0 +1,127 @@ +import * as React from 'react'; +import { Alert, Button, confirmModal, Spinner } from '@tnsu/ui-kit'; +import { useAppServices } from '../../app/AppServicesContext'; +import { EmptyState } from '../../shared/components/EmptyState'; +import { ContextMenu, IContextMenuItem, IMenuAnchor } from '../_tree/ContextMenu'; +import { TreePageHeader } from '../_tree/TreePageHeader'; +import { flattenVisible, toggleId } from '../_tree/treeRules'; +import { createWorksRepository } from './worksRepository'; +import { buildWorkTree } from './worksRules'; +import { useWorksData } from './useWorksData'; +import { useWorkEditor } from './useWorkEditor'; +import { WorksTable } from './WorksTable'; +import { WorkModal } from './WorkModal'; +import { WorkNode } from './worksTypes'; +import { WORKS_TEXTS } from './texts'; +import styles from './WorksPage.module.scss'; + +type WorkMenuKey = 'addChild' | 'edit' | 'delete'; + +const MENU_ITEMS: Array> = [ + { key: 'addChild', label: WORKS_TEXTS.menu.addChild, icon: 'plus' }, + { key: 'edit', label: WORKS_TEXTS.menu.edit, icon: 'edit' }, + { key: 'delete', label: WORKS_TEXTS.menu.delete, icon: 'trash', danger: true, dividerBefore: true } +]; + +/** Отступ меню от кнопки ⋮ — как в старом коде. */ +const MENU_OFFSET_PX = 4; + +interface Props { + objectId: string; +} + +export const WorksPageContent: React.FC = ({ objectId }) => { + const services = useAppServices(); + const repository = React.useMemo( + () => + createWorksRepository({ + sharePoint: services.sharePoint, + dataverse: services.dataverse, + siteUrl: services.settings.siteUrl, + dataverseUrl: services.settings.dataverseUrl + }), + [services] + ); + const data = useWorksData(repository, objectId); + const editor = useWorkEditor({ repository, objectId, reloadWorks: data.reloadWorks }); + const tree = React.useMemo(() => buildWorkTree(data.works), [data.works]); + // Родители свёрнуты по умолчанию — как в старом коде. + const [expandedIds, setExpandedIds] = React.useState>(new Set()); + const [menu, setMenu] = React.useState<{ work: WorkNode; anchor: IMenuAnchor } | undefined>(undefined); + const [actionError, setActionError] = React.useState(undefined); + + const openMenu = (work: WorkNode, button: HTMLElement): void => { + const rect = button.getBoundingClientRect(); + setMenu({ work, anchor: { x: rect.right, y: rect.bottom + MENU_OFFSET_PX, alignRight: true } }); + }; + + const deleteWork = async (work: WorkNode): Promise => { + const isConfirmed = await confirmModal({ + title: WORKS_TEXTS.confirmDelete.title, + message: WORKS_TEXTS.confirmDelete.message, + cancelText: WORKS_TEXTS.confirmDelete.cancel, + confirmText: WORKS_TEXTS.confirmDelete.confirm, + danger: true + }); + if (!isConfirmed) return; + setActionError(undefined); + try { + if (await repository.deleteWork(work.id)) { + await data.reloadWorks(); + } else { + setActionError(WORKS_TEXTS.deleteFailed); + } + } catch (error) { + console.error('Ошибка удаления:', error); + setActionError(WORKS_TEXTS.deleteError); + } + }; + + const onMenuSelect = (key: WorkMenuKey): void => { + const work = menu?.work; + setMenu(undefined); + if (!work) return; + if (key === 'addChild') editor.openAdd(work.id); + if (key === 'edit') editor.openEdit(work.item); + if (key === 'delete') deleteWork(work).catch(() => undefined); + }; + + const addButton = (label: string): React.ReactNode => + data.canEdit ? ( + + ) : undefined; + + const renderBody = (): React.ReactNode => { + if (data.status === 'loading') return ; + if (data.status === 'error') return {data.errorMessage}; + if (data.works.length === 0) { + return ; + } + return ( + setExpandedIds((prev) => toggleId(prev, id))} + onOpenMenu={openMenu} + /> + ); + }; + + return ( +
+ +
+ {data.canEdit &&
{addButton(WORKS_TEXTS.addWork)}
} + {actionError && {actionError}} + {renderBody()} +
+ {menu && ( + setMenu(undefined)} /> + )} + +
+ ); +}; diff --git a/src/webparts/stroyBot/pages/works/WorksTable.tsx b/src/webparts/stroyBot/pages/works/WorksTable.tsx new file mode 100644 index 0000000..444a577 --- /dev/null +++ b/src/webparts/stroyBot/pages/works/WorksTable.tsx @@ -0,0 +1,66 @@ +import * as React from 'react'; +import { DataTable, Icon, IDataTableColumn } from '@tnsu/ui-kit'; +import { WorkTitleCell } from './WorkTitleCell'; +import { formatDateRange, formatVolume } from './worksRules'; +import { WorkNode } from './worksTypes'; +import { WORKS_TEXTS } from './texts'; +import styles from './WorksPage.module.scss'; + +const COLUMNS_TEXT = WORKS_TEXTS.columns; + +interface Props { + /** Видимые строки (дети свёрнутых родителей уже отфильтрованы). */ + rows: WorkNode[]; + expandedIds: Set; + canEdit: boolean; + onToggle: (workId: string) => void; + onOpenMenu: (work: WorkNode, anchor: HTMLElement) => void; +} + +/** Таблица-дерево работ. Номер строки считается по видимым строкам — как в старом коде. */ +export const WorksTable: React.FC = ({ rows, expandedIds, canEdit, onToggle, onOpenMenu }) => { + const columns: Array> = [ + { key: 'number', title: COLUMNS_TEXT.number, width: '48px', render: (_row, index) => index + 1 }, + { + key: 'title', + title: COLUMNS_TEXT.title, + render: (row) => + }, + { key: 'unit', title: COLUMNS_TEXT.unit, width: '100px', render: (row) => row.item.unit || WORKS_TEXTS.noValue }, + { + key: 'volume', + title: COLUMNS_TEXT.volume, + width: '150px', + align: 'right', + render: (row) => formatVolume(row.item.volume) + }, + { + key: 'date', + title: COLUMNS_TEXT.date, + width: '170px', + render: (row) => formatDateRange(row.item.start, row.item.finish) + }, + { + key: 'actions', + width: '48px', + align: 'center', + render: (row) => + canEdit ? ( + // Своя кнопка, а не IconButton: меню привязывается к кнопке, а IconButton не отдаёт элемент. + + ) : undefined + } + ]; + + return row.id} />; +}; diff --git a/src/webparts/stroyBot/pages/works/texts.ts b/src/webparts/stroyBot/pages/works/texts.ts new file mode 100644 index 0000000..9bb45a5 --- /dev/null +++ b/src/webparts/stroyBot/pages/works/texts.ts @@ -0,0 +1,58 @@ +// Тексты раздела «Работы» — дословно из stroy-bot-works. + +export const WORKS_TEXTS = { + defaultHeader: 'ОБЪЕКТЫ ПРОЕКТА', + addWork: 'Добавить работу', + addFirst: '+ Добавить работу', + columns: { + number: '#', + title: 'Наименование', + unit: 'Ед. изм.', + volume: 'Проектный объём', + date: 'Дата' + }, + loading: 'Загрузка данных...', + empty: 'Работы не найдены', + loadError: 'Ошибка загрузки данных. Проверьте настройки подключения к Dataverse.', + worksLoadError: 'Не удалось загрузить список работ из Dataverse', + noValue: '—', + expand: 'Развернуть', + collapse: 'Свернуть', + actions: 'Действия', + menu: { + addChild: 'Добавить дочернюю', + edit: 'Редактировать', + delete: 'Удалить' + }, + modal: { + addTitle: 'Добавить работу', + editTitle: 'Редактировать работу', + title: 'НАИМЕНОВАНИЕ', + titlePlaceholder: 'Введите наименование работы', + unit: 'ЕД. ИЗМ.', + unitPlaceholder: 'выберите ед.изм', + volume: 'ПРОЕКТНЫЙ ОБЪЁМ', + volumePlaceholder: '0.000', + parent: 'РОДИТЕЛЬСКАЯ РАБОТА', + // Два пробела — как в старом коде. + noParent: '— без родителя —', + object: 'ОБЪЕКТ', + start: 'ДАТА НАЧАЛА', + finish: 'ДАТА ОКОНЧАНИЯ', + cancel: 'Отмена', + // Два пробела — как в старом коде. + saveMore: 'Сохранить и добавить еще', + add: 'Добавить', + save: 'Сохранить', + saving: 'Сохранение...', + saveError: 'Ошибка при сохранении. Проверьте права доступа к Dataverse.' + }, + confirmDelete: { + title: 'Подтвердите удаление', + message: 'Вы уверены, что хотите удалить выбранную работу? Это действие нельзя отменить.', + cancel: 'Отмена', + confirm: 'Удалить' + }, + deleteFailed: 'Ошибка при удалении записи.', + deleteError: 'Ошибка при удалении. Проверьте права доступа к Dataverse.' +} as const; diff --git a/src/webparts/stroyBot/pages/works/useWorkEditor.ts b/src/webparts/stroyBot/pages/works/useWorkEditor.ts new file mode 100644 index 0000000..86abfcc --- /dev/null +++ b/src/webparts/stroyBot/pages/works/useWorkEditor.ts @@ -0,0 +1,88 @@ +import * as React from 'react'; +import { buildWorkBody, IWorksRepository } from './worksRepository'; +import { isTitleValid, needsClearParent, resetFormKeepParent, workToForm } from './worksRules'; +import { EMPTY_WORK_FORM, IWork, IWorkForm, WorkModalMode } from './worksTypes'; +import { WORKS_TEXTS } from './texts'; + +interface IEditorState { + isOpen: boolean; + mode: WorkModalMode; + /** Работа, которую правят (в режиме edit). */ + editingWork: IWork | undefined; + form: IWorkForm; + isTitleInvalid: boolean; + isSaving: boolean; + saveError: string | undefined; +} + +export interface IWorkEditor extends IEditorState { + titleInputRef: React.RefObject; + openAdd: (presetParentId?: string) => void; + openEdit: (work: IWork) => void; + close: () => void; + setField: (field: keyof IWorkForm, value: string) => void; + save: (keepOpen: boolean) => Promise; +} + +const CLOSED_STATE: IEditorState = { + isOpen: false, + mode: 'add', + editingWork: undefined, + form: EMPTY_WORK_FORM, + isTitleInvalid: false, + isSaving: false, + saveError: undefined +}; + +interface IEditorDeps { + repository: IWorksRepository; + objectId: string; + reloadWorks: () => Promise; +} + +/** Модалка добавления / правки работы — логика _openAddModal / _openEditModal / _saveWork старого кода. */ +export function useWorkEditor({ repository, objectId, reloadWorks }: IEditorDeps): IWorkEditor { + const [state, setState] = React.useState(CLOSED_STATE); + const titleInputRef = React.useRef(null); + + const openAdd = (presetParentId?: string): void => + setState({ ...CLOSED_STATE, isOpen: true, mode: 'add', form: { ...EMPTY_WORK_FORM, parentId: presetParentId || '' } }); + + const openEdit = (work: IWork): void => + setState({ ...CLOSED_STATE, isOpen: true, mode: 'edit', editingWork: work, form: workToForm(work) }); + + const close = (): void => setState(CLOSED_STATE); + + const setField = (field: keyof IWorkForm, value: string): void => + setState((prev) => ({ ...prev, form: { ...prev.form, [field]: value } })); + + const save = async (keepOpen: boolean): Promise => { + const { form, mode, editingWork } = state; + if (!isTitleValid(form)) { + setState((prev) => ({ ...prev, isTitleInvalid: true })); + titleInputRef.current?.focus(); + return; + } + setState((prev) => ({ ...prev, isTitleInvalid: false, isSaving: true, saveError: undefined })); + try { + const parentNavProperty = form.parentId.trim() ? await repository.getParentNavProperty() : ''; + const body = buildWorkBody(form, objectId, parentNavProperty); + if (mode === 'edit' && editingWork) { + await repository.updateWork(editingWork.id, body); + if (needsClearParent(mode, form, editingWork)) await repository.clearParent(editingWork.id); + } else { + await repository.createWork(body); + } + setState((prev) => + keepOpen ? { ...prev, isSaving: false, form: resetFormKeepParent(prev.form) } : CLOSED_STATE + ); + await reloadWorks(); + if (keepOpen) titleInputRef.current?.focus(); + } catch (error) { + console.error('Ошибка сохранения:', error); + setState((prev) => ({ ...prev, isSaving: false, saveError: WORKS_TEXTS.modal.saveError })); + } + }; + + return { ...state, titleInputRef, openAdd, openEdit, close, setField, save }; +} diff --git a/src/webparts/stroyBot/pages/works/useWorksData.ts b/src/webparts/stroyBot/pages/works/useWorksData.ts new file mode 100644 index 0000000..34eccab --- /dev/null +++ b/src/webparts/stroyBot/pages/works/useWorksData.ts @@ -0,0 +1,82 @@ +import * as React from 'react'; +import { IWorksRepository, loadObjectContext, WorksLoadError } from './worksRepository'; +import { IWork, IWorkObject } from './worksTypes'; +import { WORKS_TEXTS } from './texts'; + +export type WorksLoadStatus = 'loading' | 'ready' | 'error'; + +interface IWorksState { + status: WorksLoadStatus; + errorMessage: string | undefined; + canEdit: boolean; + units: string[]; + object: IWorkObject | undefined; + /** Шапка: название проекта или «ОБЪЕКТЫ ПРОЕКТА». */ + headerLabel: string; + works: IWork[]; +} + +export interface IWorksData extends IWorksState { + /** Перечитать только список работ (после сохранения или удаления). */ + reloadWorks: () => Promise; +} + +const INITIAL_STATE: IWorksState = { + status: 'loading', + errorMessage: undefined, + canEdit: false, + units: [], + object: undefined, + headerLabel: WORKS_TEXTS.defaultHeader, + works: [] +}; + +function loadErrorText(error: unknown): string { + return error instanceof WorksLoadError ? WORKS_TEXTS.worksLoadError : WORKS_TEXTS.loadError; +} + +/** Загрузка в порядке stroy-bot-works: права → единицы измерения → объект → работы. */ +export function useWorksData(repository: IWorksRepository, objectId: string): IWorksData { + const [state, setState] = React.useState(INITIAL_STATE); + const isMountedRef = React.useRef(true); + + const update = React.useCallback((patch: Partial): void => { + if (isMountedRef.current) setState((prev) => ({ ...prev, ...patch })); + }, []); + + const reloadWorks = React.useCallback(async (): Promise => { + try { + const works = await repository.loadWorks(objectId); + update({ works, status: 'ready', errorMessage: undefined }); + } catch (error) { + console.error('Dataverse error:', error); + update({ status: 'error', errorMessage: loadErrorText(error) }); + } + }, [repository, objectId, update]); + + React.useEffect(() => { + isMountedRef.current = true; + const load = async (): Promise => { + update({ canEdit: await repository.checkEditPermission(objectId) }); + update({ units: await repository.loadUnits() }); + try { + const objectIdNumber = parseInt(objectId, 10); + if (objectId && !isNaN(objectIdNumber)) { + const { object, project } = await loadObjectContext(repository.fetchObject, objectIdNumber); + update({ object, headerLabel: project || WORKS_TEXTS.defaultHeader }); + } + } catch (error) { + console.error('Ошибка загрузки данных:', error); + update({ status: 'error', errorMessage: WORKS_TEXTS.loadError }); + return; + } + await reloadWorks(); + }; + load().catch(() => undefined); + return () => { + isMountedRef.current = false; + }; + }, [repository, objectId, update, reloadWorks]); + + return { ...state, reloadWorks }; +} diff --git a/src/webparts/stroyBot/pages/works/worksRepository.test.ts b/src/webparts/stroyBot/pages/works/worksRepository.test.ts new file mode 100644 index 0000000..bbcb9d4 --- /dev/null +++ b/src/webparts/stroyBot/pages/works/worksRepository.test.ts @@ -0,0 +1,209 @@ +import { + accessUrl, + buildWorkBody, + createWorksRepository, + findProjectTitle, + IWorksRepositoryDeps, + loadObjectContext, + mapWorkRow, + objectByIdUrl, + parentNavPropertyPath, + projectByTitleUrl, + unitsUrl, + worksPath +} from './worksRepository'; +import { createFakeHttpClient } from '../../shared/testing/fakeHttpClient'; +import { IWorkObject } from './worksTypes'; + +const SITE = 'https://tnsukz.sharepoint.com/sites/stroy'; +const DV = 'https://tansu.crm5.dynamics.com'; +const API = `${DV}/api/data/v9.2/`; + +describe('адреса запросов «Работы» (1:1 со stroy-bot-works)', () => { + it('объект по id с родителем', () => { + expect(objectByIdUrl(SITE, 41)).toEqual( + `${SITE}/_api/web/lists/getbytitle('Объекты')/items(41)?$select=Id,Title,project,parent/Id&$expand=parent` + ); + }); + + it('проект по названию, апостроф удваивается', () => { + expect(projectByTitleUrl(SITE, "ЖК O'Neil")).toEqual( + `${SITE}/_api/web/lists/getbytitle('Проекты')/items?$select=Id,Title&$top=1&$filter=Title eq 'ЖК O''Neil'` + ); + }); + + 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(unitsUrl(SITE)).toEqual( + `${SITE}/_api/web/lists/getbytitle('Единицы измерения')/items?$select=Title&$orderby=Title asc&$top=5000` + ); + }); + + it('работы объекта и все работы без objectId', () => { + const select = + '$select=cree9_tableid,cree9_title,cree9_unit,cree9_volume,cree9_objectid,cree9_start,cree9_finish,_cree9_parentid_value'; + expect(worksPath('41')).toEqual(`cree9_tables?${select}&$orderby=cree9_title asc&$filter=cree9_objectid eq '41'`); + expect(worksPath('')).toEqual(`cree9_tables?${select}&$orderby=cree9_title asc`); + }); + + it('поиск навигационного свойства родителя', () => { + expect(parentNavPropertyPath()).toEqual( + "EntityDefinitions(LogicalName='cree9_table')/ManyToOneRelationships" + + "?$select=ReferencingEntityNavigationPropertyName&$filter=ReferencingAttribute eq 'cree9_parentid'" + ); + }); +}); + +describe('mapWorkRow', () => { + it('переводит строку Dataverse в работу', () => { + expect( + mapWorkRow({ + cree9_tableid: 'w1', + cree9_title: 'Бетон', + cree9_objectid: '41', + cree9_unit: 'м3', + cree9_volume: 12.5, + cree9_start: '2026-10-01T00:00:00Z', + _cree9_parentid_value: 'p1' + }) + ).toEqual({ + id: 'w1', + title: 'Бетон', + objectId: '41', + unit: 'м3', + volume: 12.5, + start: '2026-10-01T00:00:00Z', + finish: undefined, + parentId: 'p1' + }); + }); + + it('пустой объём — 0, как в старом коде', () => { + expect(mapWorkRow({ cree9_tableid: 'w1' }).volume).toEqual(0); + }); +}); + +describe('buildWorkBody', () => { + const form = { title: ' Бетон ', unit: ' м3 ', volume: '', parentId: '', start: '', finish: '2026-10-20' }; + + it('пустые объём и дата начала — null, objectId только если есть', () => { + expect(buildWorkBody(form, '41', 'cree9_parentId')).toEqual({ + cree9_title: 'Бетон', + cree9_unit: 'м3', + cree9_volume: null, + cree9_start: null, + cree9_finish: '2026-10-20', + cree9_objectid: '41' + }); + expect(buildWorkBody(form, '', 'cree9_parentId')).not.toHaveProperty('cree9_objectid'); + }); + + it('родитель — через @odata.bind на навигационное свойство', () => { + const body = buildWorkBody({ ...form, volume: '1.5', parentId: 'p1' }, '41', 'cree9_ParentId'); + expect(body.cree9_volume).toEqual(1.5); + expect(body['cree9_ParentId@odata.bind']).toEqual('/cree9_tables(p1)'); + }); +}); + +describe('цепочка объектов', () => { + const objects: Record = { + 41: { id: 41, title: 'Вид работ', project: '', parentId: 30 }, + 30: { id: 30, title: 'Раздел', project: 'Вложенный', parentId: 10 }, + 10: { id: 10, title: 'Пятно', project: 'ЖК Сункар', parentId: undefined } + }; + const fetchObject = async (id: number): Promise => objects[id]; + + it('права: первый непустой project вверх по родителям', async () => { + expect(await findProjectTitle(fetchObject, 41)).toEqual('Вложенный'); + }); + + it('шапка: project самого верхнего объекта', async () => { + const context = await loadObjectContext(fetchObject, 41); + expect(context.object.title).toEqual('Вид работ'); + expect(context.project).toEqual('ЖК Сункар'); + }); + + it('цикл в родителях не зацикливает', async () => { + const cyclic = async (id: number): Promise => ({ id, title: '', project: '', parentId: id === 1 ? 2 : 1 }); + expect(await findProjectTitle(cyclic, 1)).toEqual(''); + }); +}); + +describe('createWorksRepository', () => { + const deps = (sharePoint = createFakeHttpClient(), dataverse = createFakeHttpClient()): IWorksRepositoryDeps => ({ + sharePoint, + dataverse, + siteUrl: SITE, + dataverseUrl: DV + }); + + it('право редактирования: объект → проект → пользователь → «Доступы» с edit0', async () => { + const sharePoint = createFakeHttpClient([ + { match: 'items(41)', response: { body: { Id: 41, Title: 'Пятно', project: 'ЖК Сункар' } } }, + { match: "getbytitle('Проекты')", response: { body: { value: [{ Id: 5 }] } } }, + { match: 'currentuser', response: { body: { Id: 42 } } }, + { match: "getbytitle('Доступы')", response: { body: { value: [{ edit0: true }] } } } + ]); + const repository = createWorksRepository(deps(sharePoint)); + expect(await repository.checkEditPermission('41')).toBe(true); + expect(sharePoint.requests[3].url).toEqual(accessUrl(SITE, 5, 42)); + }); + + it('без objectId или при ошибке — нет права', async () => { + const repository = createWorksRepository(deps(createFakeHttpClient([{ match: 'items', response: { status: 500 } }]))); + expect(await repository.checkEditPermission('')).toBe(false); + expect(await repository.checkEditPermission('41')).toBe(false); + }); + + it('единицы: все страницы по odata.nextLink', async () => { + const sharePoint = createFakeHttpClient([ + { match: 'page2', response: { body: { value: [{ Title: 'т' }] } } }, + { match: 'Единицы', response: { body: { value: [{ Title: 'м3' }, {}], 'odata.nextLink': `${SITE}/page2` } } } + ]); + expect(await createWorksRepository(deps(sharePoint)).loadUnits()).toEqual(['м3', 'т']); + }); + + it('правка: PATCH с If-Match, очистка родителя — DELETE $ref, 404 не ошибка', async () => { + const dataverse = createFakeHttpClient([ + { match: 'EntityDefinitions', response: { body: { value: [{ ReferencingEntityNavigationPropertyName: 'cree9_ParentId' }] } } }, + { match: '/$ref', response: { status: 404 } }, + { match: 'cree9_tables(w1)', response: { status: 204 } } + ]); + const repository = createWorksRepository(deps(undefined, dataverse)); + await repository.updateWork('w1', { cree9_title: 'X' }); + await repository.clearParent('w1'); + + expect(dataverse.requests[0]).toEqual({ + url: `${API}cree9_tables(w1)`, + request: expect.objectContaining({ method: 'PATCH', body: '{"cree9_title":"X"}' }) + }); + expect(dataverse.requests[0].request.headers).toEqual(expect.objectContaining({ 'If-Match': '*' })); + expect(dataverse.requests[2].url).toEqual(`${API}cree9_tables(w1)/cree9_ParentId/$ref`); + expect(dataverse.requests[2].request.method).toEqual('DELETE'); + }); + + it('навигационное свойство: без метаданных — имя колонки, и запрос кешируется', async () => { + const dataverse = createFakeHttpClient([{ match: 'EntityDefinitions', response: { status: 403 } }]); + const repository = createWorksRepository(deps(undefined, dataverse)); + expect(await repository.getParentNavProperty()).toEqual('cree9_parentid'); + expect(await repository.getParentNavProperty()).toEqual('cree9_parentid'); + expect(dataverse.requests).toHaveLength(1); + }); + + it('удаление: успех и ошибка сервера', async () => { + const ok = createWorksRepository(deps(undefined, createFakeHttpClient([{ match: 'cree9_tables', response: { status: 204 } }]))); + const failed = createWorksRepository(deps(undefined, createFakeHttpClient([{ match: 'cree9_tables', response: { status: 403 } }]))); + expect(await ok.deleteWork('w1')).toBe(true); + expect(await failed.deleteWork('w1')).toBe(false); + }); + + it('ошибка загрузки работ — WorksLoadError', async () => { + const repository = createWorksRepository(deps(undefined, createFakeHttpClient([{ match: 'cree9_tables', response: { status: 500 } }]))); + await expect(repository.loadWorks('41')).rejects.toThrow(); + }); +}); diff --git a/src/webparts/stroyBot/pages/works/worksRepository.ts b/src/webparts/stroyBot/pages/works/worksRepository.ts new file mode 100644 index 0000000..50772f4 --- /dev/null +++ b/src/webparts/stroyBot/pages/works/worksRepository.ts @@ -0,0 +1,312 @@ +import { IHttpClient, IHttpResponse } from '../../services/http/IHttpClient'; +import { fetchCurrentUserId, getJson, listItemsUrl } from '../../shared/sharepoint/spRest'; +import { dataverseUrl } from '../../shared/dataverse/dataverseRest'; +import { LISTS } from '../../config/lists'; +import { DV_TABLES } from '../../config/dataverse'; +import { DataverseWorkRow, IWork, IWorkForm, IWorkObject } from './worksTypes'; + +// Запросы страницы «Работы» — 1:1 со stroy-bot-works (StroyBotWorksWebPart.ts). + +/** Колонки таблицы работ в Dataverse. */ +export const WORK_COLUMNS = { + id: 'cree9_tableid', + title: 'cree9_title', + unit: 'cree9_unit', + volume: 'cree9_volume', + objectId: 'cree9_objectid', + start: 'cree9_start', + finish: 'cree9_finish', + /** Lookup на родителя: имя колонки (для поиска навигационного свойства). */ + parent: 'cree9_parentid', + /** Значение lookup при чтении. */ + parentValue: '_cree9_parentid_value' +} as const; + +const WORKS_TABLE = DV_TABLES.works; +/** Логическое имя сущности — имя таблицы без «s», как в старом коде. */ +const WORKS_ENTITY = WORKS_TABLE.replace(/s$/, ''); +const UNITS_LIMIT = 5000; + +/** Заголовки Dataverse — как _dvHeaders() старого кода. */ +const DV_HEADERS: Record = { + Accept: 'application/json', + 'Content-Type': 'application/json;odata.metadata=minimal', + 'OData-MaxVersion': '4.0', + 'OData-Version': '4.0', + Prefer: 'return=representation' +}; +const DV_DELETE_HEADERS: Record = { 'OData-MaxVersion': '4.0', 'OData-Version': '4.0' }; + +const HTTP_NO_CONTENT = 204; +const HTTP_NOT_FOUND = 404; + +// ── Адреса ─────────────────────────────────────────────────────────────────── + +export function objectByIdUrl(siteUrl: string, id: number): string { + return `${listItemsUrl(siteUrl, LISTS.objects)}(${id})?$select=Id,Title,project,parent/Id&$expand=parent`; +} + +export function projectByTitleUrl(siteUrl: string, projectTitle: string): string { + const escaped = projectTitle.replace(/'/g, "''"); + return `${listItemsUrl(siteUrl, LISTS.projects)}?$select=Id,Title&$top=1&$filter=Title eq '${escaped}'`; +} + +export function accessUrl(siteUrl: string, projectId: number, userId: number): string { + return ( + `${listItemsUrl(siteUrl, LISTS.access)}?$select=Id,edit0&$top=1` + + `&$filter=projectId eq ${projectId} and userId eq ${userId}` + ); +} + +export function unitsUrl(siteUrl: string): string { + return `${listItemsUrl(siteUrl, LISTS.units)}?$select=Title&$orderby=Title asc&$top=${UNITS_LIMIT}`; +} + +export function worksPath(objectId: string): string { + const c = WORK_COLUMNS; + const select = [c.id, c.title, c.unit, c.volume, c.objectId, c.start, c.finish, c.parentValue].join(','); + const filter = objectId ? `&$filter=${c.objectId} eq '${objectId}'` : ''; + return `${WORKS_TABLE}?$select=${select}&$orderby=${c.title} asc${filter}`; +} + +export function parentNavPropertyPath(): string { + return ( + `EntityDefinitions(LogicalName='${WORKS_ENTITY}')/ManyToOneRelationships` + + `?$select=ReferencingEntityNavigationPropertyName` + + `&$filter=ReferencingAttribute eq '${WORK_COLUMNS.parent}'` + ); +} + +// ── Преобразования ─────────────────────────────────────────────────────────── + +export function mapWorkRow(row: DataverseWorkRow): IWork { + const c = WORK_COLUMNS; + return { + id: String(row[c.id] || ''), + title: String(row[c.title] || ''), + objectId: String(row[c.objectId] || ''), + unit: String(row[c.unit] || ''), + volume: Number(row[c.volume] || 0), + start: row[c.start] ? String(row[c.start]) : undefined, + finish: row[c.finish] ? String(row[c.finish]) : undefined, + parentId: row[c.parentValue] ? String(row[c.parentValue]) : undefined + }; +} + +/** + * Тело записи работы. Пустые объём и даты — null (Dataverse очищает поле), + * objectId — только если страница открыта для объекта, родитель — через @odata.bind. + */ +export function buildWorkBody(form: IWorkForm, objectId: string, parentNavProperty: string): Record { + const c = WORK_COLUMNS; + /* eslint-disable @rushstack/no-new-null -- Dataverse Web API очищает поле значением null. */ + const body: Record = { + [c.title]: form.title.trim(), + [c.unit]: form.unit.trim(), + [c.volume]: form.volume ? parseFloat(form.volume) : null, + [c.start]: form.start ? form.start : null, + [c.finish]: form.finish ? form.finish : null + }; + /* eslint-enable @rushstack/no-new-null */ + if (objectId) { + body[c.objectId] = objectId; + } + const parentId = form.parentId.trim(); + if (parentId) { + body[`${parentNavProperty}@odata.bind`] = `/${WORKS_TABLE}(${parentId})`; + } + return body; +} + +// ── Репозиторий ────────────────────────────────────────────────────────────── + +export interface IWorksRepositoryDeps { + sharePoint: IHttpClient; + dataverse: IHttpClient; + siteUrl: string; + dataverseUrl: string; +} + +export interface IWorksRepository { + fetchObject: (id: number) => Promise; + checkEditPermission: (objectId: string) => Promise; + loadUnits: () => Promise; + loadWorks: (objectId: string) => Promise; + getParentNavProperty: () => Promise; + createWork: (body: Record) => Promise; + updateWork: (id: string, body: Record) => Promise; + clearParent: (id: string) => Promise; + /** true — удалено; false — сервер ответил ошибкой (старый текст «Ошибка при удалении записи.»). */ + deleteWork: (id: string) => Promise; +} + +/** Ошибка загрузки списка работ — отдельный текст на странице. */ +export class WorksLoadError extends Error { + constructor(details: string) { + super(details); + Object.setPrototypeOf(this, WorksLoadError.prototype); + } +} + +export function createWorksRepository(deps: IWorksRepositoryDeps): IWorksRepository { + const { sharePoint, dataverse, siteUrl } = deps; + let navPropertyPromise: Promise | undefined; + + const dvFetch = (path: string, method: 'GET' | 'POST' | 'PATCH' | 'DELETE', body?: unknown, extra = {}): Promise => + dataverse.fetch(dataverseUrl(deps.dataverseUrl, path), { + method, + headers: method === 'DELETE' ? { ...DV_DELETE_HEADERS, ...extra } : { ...DV_HEADERS, ...extra }, + body: body === undefined ? undefined : JSON.stringify(body) + }); + + const fetchObject = async (id: number): Promise => { + const response = await sharePoint.fetch(objectByIdUrl(siteUrl, id)); + if (!response.ok) throw new Error(`Не удалось загрузить объект ${id}`); + const data = (await response.json()) as { Id: number; Title?: string; project?: string; parent?: { Id: number } }; + return { id: data.Id, title: data.Title || '', project: data.project || '', parentId: data.parent?.Id }; + }; + + const discoverParentNavProperty = async (): Promise => { + try { + const response = await dvFetch(parentNavPropertyPath(), 'GET'); + if (response.ok) { + const data = (await response.json()) as { value?: Array<{ ReferencingEntityNavigationPropertyName?: string }> }; + const name = data.value?.[0]?.ReferencingEntityNavigationPropertyName; + if (name) return name; + } + } catch { + // Метаданные недоступны — имя колонки по умолчанию, как в старом коде. + } + return WORK_COLUMNS.parent; + }; + + // Кешируется сам запрос: метаданные спрашиваются один раз, даже при одновременных вызовах. + const getParentNavProperty = (): Promise => { + if (!navPropertyPromise) navPropertyPromise = discoverParentNavProperty(); + return navPropertyPromise; + }; + + return { + fetchObject, + getParentNavProperty, + checkEditPermission: (objectId) => checkEditPermission(deps, fetchObject, objectId), + loadUnits: () => loadUnits(sharePoint, siteUrl), + + loadWorks: async (objectId) => { + const response = await dvFetch(worksPath(objectId), 'GET'); + if (!response.ok) throw new WorksLoadError(await response.text()); + const data = (await response.json()) as { value?: DataverseWorkRow[] }; + return (data.value || []).map(mapWorkRow); + }, + + createWork: async (body) => { + const response = await dvFetch(WORKS_TABLE, 'POST', body); + if (!response.ok) throw new Error(await response.text()); + }, + + updateWork: async (id, body) => { + const response = await dvFetch(`${WORKS_TABLE}(${id})`, 'PATCH', body, { 'If-Match': '*' }); + if (!response.ok && response.status !== HTTP_NO_CONTENT) throw new Error(await response.text()); + }, + + clearParent: async (id) => { + const navProperty = await getParentNavProperty(); + const response = await dvFetch(`${WORKS_TABLE}(${id})/${navProperty}/$ref`, 'DELETE'); + // 204 — очищено, 404 — родителя и так не было: оба варианта нормальны. + if (!response.ok && response.status !== HTTP_NO_CONTENT && response.status !== HTTP_NOT_FOUND) { + throw new Error(await response.text()); + } + }, + + deleteWork: async (id) => { + const response = await dvFetch(`${WORKS_TABLE}(${id})`, 'DELETE'); + return response.ok || response.status === HTTP_NO_CONTENT; + } + }; +} + +/** Справочник единиц: все страницы через odata.nextLink. Ошибка — пустой справочник, как в старом коде. */ +async function loadUnits(sharePoint: IHttpClient, siteUrl: string): Promise { + const units: string[] = []; + try { + let nextUrl = unitsUrl(siteUrl); + while (nextUrl) { + const response = await sharePoint.fetch(nextUrl); + if (!response.ok) break; + const data = (await response.json()) as { value?: Array<{ Title?: string }>; 'odata.nextLink'?: string }; + (data.value || []).forEach((item) => { + if (item.Title) units.push(item.Title); + }); + nextUrl = data['odata.nextLink'] || ''; + } + } catch { + // Список не найден или недоступен. + } + return units; +} + +/** + * Право редактирования: проект ищется подъёмом по родителям объекта до первого непустого `project`, + * затем «Проекты» по названию → текущий пользователь → «Доступы» с edit0. Любая ошибка — нет права. + */ +async function checkEditPermission( + deps: IWorksRepositoryDeps, + fetchObject: (id: number) => Promise, + objectId: string +): Promise { + const startId = parseInt(objectId, 10); + if (isNaN(startId)) return false; + try { + const projectTitle = await findProjectTitle(fetchObject, startId); + if (!projectTitle) return false; + + const projects = await getJson<{ value: Array<{ Id: number }> }>( + deps.sharePoint, + projectByTitleUrl(deps.siteUrl, projectTitle) + ); + if (!projects.value.length) return false; + + const userId = await fetchCurrentUserId(deps.sharePoint, deps.siteUrl); + const access = await getJson<{ value: Array<{ edit0?: boolean }> }>( + deps.sharePoint, + accessUrl(deps.siteUrl, projects.value[0].Id, userId) + ); + return access.value.length > 0 && access.value[0].edit0 === true; + } catch { + return false; + } +} + +/** + * Объект страницы и проект для шапки. Как _buildObjectPath старого кода: проходит всю цепочку + * до корня, и проект берётся у самого верхнего объекта с непустым `project`. + */ +export async function loadObjectContext( + fetchObject: (id: number) => Promise, + objectId: number +): Promise<{ object: IWorkObject; project: string }> { + const object = await fetchObject(objectId); + const visited = new Set([objectId]); + let project = object.project; + let currentId = object.parentId; + while (currentId !== undefined && !visited.has(currentId)) { + visited.add(currentId); + const parent = await fetchObject(currentId); + if (parent.project) project = parent.project; + currentId = parent.parentId; + } + return { object, project }; +} + +/** Название проекта для проверки прав: первый непустой `project` вверх по родителям, с защитой от циклов. */ +export async function findProjectTitle(fetchObject: (id: number) => Promise, startId: number): Promise { + const visited = new Set(); + let currentId: number | undefined = startId; + while (currentId !== undefined && !visited.has(currentId)) { + visited.add(currentId); + const object = await fetchObject(currentId); + if (object.project) return object.project; + currentId = object.parentId; + } + return ''; +} diff --git a/src/webparts/stroyBot/pages/works/worksRules.test.ts b/src/webparts/stroyBot/pages/works/worksRules.test.ts new file mode 100644 index 0000000..2c04c98 --- /dev/null +++ b/src/webparts/stroyBot/pages/works/worksRules.test.ts @@ -0,0 +1,95 @@ +import { + buildWorkTree, + formatDateRange, + formatVolume, + isTitleValid, + needsClearParent, + parentOptions, + resetFormKeepParent, + unitOptions, + workToForm +} from './worksRules'; +import { EMPTY_WORK_FORM, IWork } from './worksTypes'; + +const work = (id: string, title: string, parentId?: string): IWork => ({ + id, + title, + objectId: '41', + unit: '', + volume: 0, + parentId +}); + +const works = [work('a', 'Фундамент'), work('b', 'Бетон', 'a'), work('c', 'Арматура', 'b'), work('d', 'Кровля')]; + +describe('formatVolume / formatDateRange', () => { + it('объём ru-RU с тремя знаками', () => { + expect(formatVolume(1234.5)).toMatch(/^1\s234,500$/); + expect(formatVolume(undefined)).toEqual('—'); + }); + + it('диапазон дат, одна дата или прочерк', () => { + expect(formatDateRange('2026-10-01T00:00:00', '2026-10-20T00:00:00')).toEqual('01.10.26 – 20.10.26'); + expect(formatDateRange('2026-10-01T00:00:00', undefined)).toEqual('01.10.26'); + expect(formatDateRange(undefined, undefined)).toEqual('—'); + expect(formatDateRange('не дата', undefined)).toEqual('—'); + }); +}); + +describe('unitOptions', () => { + it('текущее значение не из справочника — наверх', () => { + expect(unitOptions(['м3', 'т'], 'шт').map((o) => o.value)).toEqual(['шт', 'м3', 'т']); + expect(unitOptions(['м3', 'т'], 'т').map((o) => o.value)).toEqual(['м3', 'т']); + }); +}); + +describe('parentOptions', () => { + const tree = buildWorkTree(works); + + it('дерево с отступом по 4 пробела, первым — «без родителя»', () => { + expect(parentOptions(tree, undefined).map((o) => o.label)).toEqual([ + '— без родителя —', + 'Фундамент', + ' Бетон', + ' Арматура', + 'Кровля' + ]); + }); + + it('при правке нет самой работы и её потомков', () => { + expect(parentOptions(tree, 'b').map((o) => o.value)).toEqual(['', 'a', 'd']); + }); +}); + +describe('форма', () => { + it('правка: даты обрезаются до YYYY-MM-DD', () => { + expect(workToForm({ ...work('a', 'X', 'p'), unit: 'м3', volume: 2, start: '2026-10-01T05:00:00Z' })).toEqual({ + title: 'X', + unit: 'м3', + volume: '2', + parentId: 'p', + start: '2026-10-01', + finish: '' + }); + }); + + it('«сохранить и добавить еще» оставляет только родителя', () => { + expect(resetFormKeepParent({ ...EMPTY_WORK_FORM, title: 'X', unit: 'т', parentId: 'a' })).toEqual({ + ...EMPTY_WORK_FORM, + parentId: 'a' + }); + }); + + it('наименование обязательно (пробелы не считаются)', () => { + expect(isTitleValid({ ...EMPTY_WORK_FORM, title: ' ' })).toBe(false); + expect(isTitleValid({ ...EMPTY_WORK_FORM, title: 'Бетон' })).toBe(true); + }); + + it('очистка родителя — только при правке, если родителя убрали', () => { + const child = work('b', 'Бетон', 'a'); + expect(needsClearParent('edit', EMPTY_WORK_FORM, child)).toBe(true); + expect(needsClearParent('edit', { ...EMPTY_WORK_FORM, parentId: 'a' }, child)).toBe(false); + expect(needsClearParent('add', EMPTY_WORK_FORM, child)).toBe(false); + expect(needsClearParent('edit', EMPTY_WORK_FORM, work('a', 'Корень'))).toBe(false); + }); +}); diff --git a/src/webparts/stroyBot/pages/works/worksRules.ts b/src/webparts/stroyBot/pages/works/worksRules.ts new file mode 100644 index 0000000..1ec03a5 --- /dev/null +++ b/src/webparts/stroyBot/pages/works/worksRules.ts @@ -0,0 +1,94 @@ +import { ISelectOption } from '@tnsu/ui-kit'; +import { buildTree, collectDescendantIds, ITree } from '../_tree/treeRules'; +import { IWork, IWorkForm, WorkNode } from './worksTypes'; +import { WORKS_TEXTS } from './texts'; + +// Чистые правила страницы «Работы» — 1:1 с _buildWorkTree / _populate*Select / _format* в stroy-bot-works. + +/** Отступ уровня в списке «Родительская работа» — 4 пробела, как в старом коде. */ +const PARENT_OPTION_INDENT = ' '; + +export function buildWorkTree(works: IWork[]): ITree { + return buildTree( + works, + (work) => work.id, + (work) => work.parentId + ); +} + +/** Проектный объём: ru-RU, ровно 3 знака после запятой. */ +export function formatVolume(value: number | undefined): string { + if (value === undefined) return WORKS_TEXTS.noValue; + return value.toLocaleString('ru-RU', { minimumFractionDigits: 3, maximumFractionDigits: 3 }); +} + +/** Дата `dd.mm.yy`; пустая или неверная — пустая строка. */ +export function formatDate(iso: string | undefined): string { + if (!iso) return ''; + const date = new Date(iso); + if (isNaN(date.getTime())) return ''; + return date.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: '2-digit' }); +} + +/** «начало – окончание», одна из дат или «—». */ +export function formatDateRange(start: string | undefined, finish: string | undefined): string { + const startText = formatDate(start); + const finishText = formatDate(finish); + if (startText && finishText) return `${startText} – ${finishText}`; + return startText || finishText || WORKS_TEXTS.noValue; +} + +/** Единицы измерения для выбора. Текущее значение не из справочника добавляется наверх. */ +export function unitOptions(units: string[], selected: string): ISelectOption[] { + const all = selected && units.indexOf(selected) === -1 ? [selected, ...units] : units; + return all.map((unit) => ({ value: unit, label: unit })); +} + +/** + * Список «Родительская работа»: дерево с отступами, без самой работы и её потомков (при правке). + * Первый пункт — «без родителя». + */ +export function parentOptions(tree: ITree, excludeId: string | undefined): ISelectOption[] { + const excluded = new Set(); + if (excludeId) { + excluded.add(excludeId); + collectDescendantIds(tree.byId, excludeId).forEach((id) => excluded.add(id)); + } + + const options: ISelectOption[] = [{ value: '', label: WORKS_TEXTS.modal.noParent }]; + const addOptions = (nodes: WorkNode[]): void => { + nodes.forEach((node) => { + if (excluded.has(node.id)) return; + options.push({ value: node.id, label: `${PARENT_OPTION_INDENT.repeat(node.level)}${node.item.title}` }); + addOptions(node.children); + }); + }; + addOptions(tree.roots); + return options; +} + +/** Значения модалки для правки работы (даты — первые 10 символов ISO). */ +export function workToForm(work: IWork): IWorkForm { + return { + title: work.title, + unit: work.unit || '', + volume: work.volume !== undefined ? work.volume.toString() : '', + parentId: work.parentId || '', + start: work.start ? work.start.substring(0, 10) : '', + finish: work.finish ? work.finish.substring(0, 10) : '' + }; +} + +/** «Сохранить и добавить еще»: поля очищаются, выбранный родитель остаётся. */ +export function resetFormKeepParent(form: IWorkForm): IWorkForm { + return { title: '', unit: '', volume: '', parentId: form.parentId, start: '', finish: '' }; +} + +export function isTitleValid(form: IWorkForm): boolean { + return form.title.trim() !== ''; +} + +/** При правке родителя убрали — ссылку нужно очистить отдельным запросом ($ref). */ +export function needsClearParent(mode: 'add' | 'edit', form: IWorkForm, current: IWork | undefined): boolean { + return mode === 'edit' && !form.parentId.trim() && !!current?.parentId; +} diff --git a/src/webparts/stroyBot/pages/works/worksTypes.ts b/src/webparts/stroyBot/pages/works/worksTypes.ts new file mode 100644 index 0000000..b0e393e --- /dev/null +++ b/src/webparts/stroyBot/pages/works/worksTypes.ts @@ -0,0 +1,50 @@ +import { ITreeNode } from '../_tree/treeRules'; + +/** Работа (строка таблицы Dataverse cree9_tables) в удобном виде. */ +export interface IWork { + id: string; + title: string; + /** Id элемента списка «Объекты» строкой. */ + objectId: string; + unit: string; + volume: number; + /** ISO-дата из Dataverse. */ + start?: string; + finish?: string; + parentId?: string; +} + +export type WorkNode = ITreeNode; + +/** Строка ответа Dataverse по таблице работ. */ +export type DataverseWorkRow = Record; + +/** Элемент списка «Объекты» в том виде, в каком его читает страница работ. */ +export interface IWorkObject { + id: number; + title: string; + /** Текстовое поле с названием проекта (может быть пустым у вложенных объектов). */ + project: string; + parentId: number | undefined; +} + +/** Поля модалки — строками, как в полях ввода. */ +export interface IWorkForm { + title: string; + unit: string; + volume: string; + parentId: string; + start: string; + finish: string; +} + +export const EMPTY_WORK_FORM: IWorkForm = { + title: '', + unit: '', + volume: '', + parentId: '', + start: '', + finish: '' +}; + +export type WorkModalMode = 'add' | 'edit';