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:
@@ -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`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user