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) <noreply@anthropic.com>
This commit is contained in:
Тимур Городецкий
2026-10-16 09:55:54 +05:00
parent bfb3eb7b75
commit 673ace2ba6
16 changed files with 1444 additions and 1 deletions
+8 -1
View File
@@ -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`.
---
+3
View File
@@ -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 <ImportWorksPage />;
case 'objects':
return <ObjectsPage />;
case 'works':
return <WorksPage />;
default:
return <SectionPlaceholder />;
}
@@ -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<IWork, string>;
units: string[];
objectTitle: string;
}
/** Модалка «Добавить работу» / «Редактировать работу». */
export const WorkModal: React.FC<Props> = ({ editor, tree, units, objectTitle }) => {
const { form, mode, isSaving, setField } = editor;
const isAddMode = mode === 'add';
const saveLabel = isAddMode ? TEXTS.add : TEXTS.save;
const footer = (
<>
<Button variant="secondary" onClick={editor.close}>
{TEXTS.cancel}
</Button>
{isAddMode && (
<Button variant="secondary" disabled={isSaving} onClick={() => editor.save(true)}>
{TEXTS.saveMore}
</Button>
)}
<Button variant="primary" disabled={isSaving} onClick={() => editor.save(false)}>
{isSaving ? TEXTS.saving : saveLabel}
</Button>
</>
);
return (
<Dialog
open={editor.isOpen}
title={isAddMode ? TEXTS.addTitle : TEXTS.editTitle}
onDismiss={editor.close}
footer={footer}
width="md"
>
<div className={styles.modalFields}>
<FormField label={TEXTS.title} htmlFor="work-title" required full>
<FormInput
id="work-title"
ref={editor.titleInputRef}
value={form.title}
onChange={(value) => setField('title', value)}
placeholder={TEXTS.titlePlaceholder}
isInvalid={editor.isTitleInvalid}
autoFocus
/>
</FormField>
<FormField label={TEXTS.unit} htmlFor="work-unit" full>
<Select
id="work-unit"
value={form.unit}
onChange={(value) => setField('unit', value)}
options={unitOptions(units, form.unit)}
placeholder={TEXTS.unitPlaceholder}
/>
</FormField>
<FormField label={TEXTS.volume} htmlFor="work-volume" full>
<FormInput
id="work-volume"
type="number"
step="0.001"
value={form.volume}
onChange={(value) => setField('volume', value)}
placeholder={TEXTS.volumePlaceholder}
/>
</FormField>
<FormField label={TEXTS.parent} htmlFor="work-parent" full>
<Select
id="work-parent"
value={form.parentId}
onChange={(value) => setField('parentId', value)}
options={parentOptions(tree, isAddMode ? undefined : editor.editingWork?.id)}
/>
</FormField>
<FormField label={TEXTS.object} full>
<div className={styles.readonlyField}>{objectTitle}</div>
</FormField>
<FormField label={TEXTS.start} htmlFor="work-start" full>
<TextInput id="work-start" type="date" value={form.start} onChange={(value) => setField('start', value)} />
</FormField>
<FormField label={TEXTS.finish} htmlFor="work-finish" full>
<TextInput id="work-finish" type="date" value={form.finish} onChange={(value) => setField('finish', value)} />
</FormField>
{editor.saveError && <Alert variant="err">{editor.saveError}</Alert>}
</div>
</Dialog>
);
};
@@ -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<Props> = ({ node, isExpanded, onToggle }) => {
const hasChildren = node.children.length > 0;
const className = hasChildren ? `${styles.titleCell} ${styles.parentTitle}` : styles.titleCell;
return (
<span className={className} style={{ paddingLeft: node.level * INDENT_PER_LEVEL_PX }}>
{hasChildren ? (
<button
type="button"
className={styles.toggleButton}
title={isExpanded ? WORKS_TEXTS.collapse : WORKS_TEXTS.expand}
onClick={(event) => {
event.stopPropagation();
onToggle(node.id);
}}
>
<Icon name={isExpanded ? 'chevron-down' : 'chevron-right'} size="sm" />
</button>
) : (
<span className={styles.toggleSpacer} />
)}
<span>{node.item.title}</span>
</span>
);
};
@@ -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;
}
@@ -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 <WorksPageContent key={route.objectId ?? ''} objectId={route.objectId ?? ''} />;
};
@@ -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<IContextMenuItem<WorkMenuKey>> = [
{ 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<Props> = ({ 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<Set<string>>(new Set());
const [menu, setMenu] = React.useState<{ work: WorkNode; anchor: IMenuAnchor } | undefined>(undefined);
const [actionError, setActionError] = React.useState<string | undefined>(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<void> => {
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 ? (
<Button variant="accent" onClick={() => editor.openAdd()}>
{label}
</Button>
) : undefined;
const renderBody = (): React.ReactNode => {
if (data.status === 'loading') return <Spinner block label={WORKS_TEXTS.loading} />;
if (data.status === 'error') return <Alert variant="err">{data.errorMessage}</Alert>;
if (data.works.length === 0) {
return <EmptyState icon="clipboard" title={WORKS_TEXTS.empty} action={addButton(WORKS_TEXTS.addFirst)} />;
}
return (
<WorksTable
rows={flattenVisible(tree.roots, expandedIds)}
expandedIds={expandedIds}
canEdit={data.canEdit}
onToggle={(id) => setExpandedIds((prev) => toggleId(prev, id))}
onOpenMenu={openMenu}
/>
);
};
return (
<div className={styles.page}>
<TreePageHeader icon="clipboard" title={data.headerLabel} />
<div className={styles.container}>
{data.canEdit && <div className={styles.toolbar}>{addButton(WORKS_TEXTS.addWork)}</div>}
{actionError && <Alert variant="err">{actionError}</Alert>}
{renderBody()}
</div>
{menu && (
<ContextMenu anchor={menu.anchor} items={MENU_ITEMS} onSelect={onMenuSelect} onClose={() => setMenu(undefined)} />
)}
<WorkModal editor={editor} tree={tree} units={data.units} objectTitle={data.object?.title ?? ''} />
</div>
);
};
@@ -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<string>;
canEdit: boolean;
onToggle: (workId: string) => void;
onOpenMenu: (work: WorkNode, anchor: HTMLElement) => void;
}
/** Таблица-дерево работ. Номер строки считается по видимым строкам — как в старом коде. */
export const WorksTable: React.FC<Props> = ({ rows, expandedIds, canEdit, onToggle, onOpenMenu }) => {
const columns: Array<IDataTableColumn<WorkNode>> = [
{ key: 'number', title: COLUMNS_TEXT.number, width: '48px', render: (_row, index) => index + 1 },
{
key: 'title',
title: COLUMNS_TEXT.title,
render: (row) => <WorkTitleCell node={row} isExpanded={expandedIds.has(row.id)} onToggle={onToggle} />
},
{ 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 не отдаёт элемент.
<button
type="button"
className={styles.dotsButton}
title={WORKS_TEXTS.actions}
onClick={(event) => {
event.stopPropagation();
onOpenMenu(row, event.currentTarget);
}}
>
<Icon name="more" size="sm" />
</button>
) : undefined
}
];
return <DataTable columns={columns} rows={rows} rowKey={(row) => row.id} />;
};
@@ -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;
@@ -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<HTMLInputElement>;
openAdd: (presetParentId?: string) => void;
openEdit: (work: IWork) => void;
close: () => void;
setField: (field: keyof IWorkForm, value: string) => void;
save: (keepOpen: boolean) => Promise<void>;
}
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<void>;
}
/** Модалка добавления / правки работы — логика _openAddModal / _openEditModal / _saveWork старого кода. */
export function useWorkEditor({ repository, objectId, reloadWorks }: IEditorDeps): IWorkEditor {
const [state, setState] = React.useState<IEditorState>(CLOSED_STATE);
const titleInputRef = React.useRef<HTMLInputElement>(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<void> => {
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 };
}
@@ -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<void>;
}
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<IWorksState>(INITIAL_STATE);
const isMountedRef = React.useRef<boolean>(true);
const update = React.useCallback((patch: Partial<IWorksState>): void => {
if (isMountedRef.current) setState((prev) => ({ ...prev, ...patch }));
}, []);
const reloadWorks = React.useCallback(async (): Promise<void> => {
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<void> => {
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 };
}
@@ -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<number, IWorkObject> = {
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<IWorkObject> => 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<IWorkObject> => ({ 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();
});
});
@@ -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<string, string> = {
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<string, string> = { '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<string, unknown> {
const c = WORK_COLUMNS;
/* eslint-disable @rushstack/no-new-null -- Dataverse Web API очищает поле значением null. */
const body: Record<string, unknown> = {
[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<IWorkObject>;
checkEditPermission: (objectId: string) => Promise<boolean>;
loadUnits: () => Promise<string[]>;
loadWorks: (objectId: string) => Promise<IWork[]>;
getParentNavProperty: () => Promise<string>;
createWork: (body: Record<string, unknown>) => Promise<void>;
updateWork: (id: string, body: Record<string, unknown>) => Promise<void>;
clearParent: (id: string) => Promise<void>;
/** true — удалено; false — сервер ответил ошибкой (старый текст «Ошибка при удалении записи.»). */
deleteWork: (id: string) => Promise<boolean>;
}
/** Ошибка загрузки списка работ — отдельный текст на странице. */
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<string> | undefined;
const dvFetch = (path: string, method: 'GET' | 'POST' | 'PATCH' | 'DELETE', body?: unknown, extra = {}): Promise<IHttpResponse> =>
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<IWorkObject> => {
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<string> => {
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<string> => {
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<string[]> {
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<IWorkObject>,
objectId: string
): Promise<boolean> {
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<IWorkObject>,
objectId: number
): Promise<{ object: IWorkObject; project: string }> {
const object = await fetchObject(objectId);
const visited = new Set<number>([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<IWorkObject>, startId: number): Promise<string> {
const visited = new Set<number>();
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 '';
}
@@ -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);
});
});
@@ -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<IWork, string> {
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<IWork, string>, excludeId: string | undefined): ISelectOption[] {
const excluded = new Set<string>();
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;
}
@@ -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<IWork, string>;
/** Строка ответа Dataverse по таблице работ. */
export type DataverseWorkRow = Record<string, string | number | undefined>;
/** Элемент списка «Объекты» в том виде, в каком его читает страница работ. */
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';