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`)
|
- [ ] Навигационное свойство родителя определяется через `EntityDefinitions` (fallback `cree9_parentid`)
|
||||||
- [ ] Проект находится подъёмом по родителям объекта до непустого `project`
|
- [ ] Проект находится подъёмом по родителям объекта до непустого `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 { ReportsPage } from '../pages/reports/ReportsPage';
|
||||||
import { ImportWorksPage } from '../pages/import/ImportWorksPage';
|
import { ImportWorksPage } from '../pages/import/ImportWorksPage';
|
||||||
import { ObjectsPage } from '../pages/objects/ObjectsPage';
|
import { ObjectsPage } from '../pages/objects/ObjectsPage';
|
||||||
|
import { WorksPage } from '../pages/works/WorksPage';
|
||||||
|
|
||||||
/** Показывает страницу текущего раздела. Разделы подключаются сюда по мере переноса. */
|
/** Показывает страницу текущего раздела. Разделы подключаются сюда по мере переноса. */
|
||||||
export const PageOutlet: React.FC = () => {
|
export const PageOutlet: React.FC = () => {
|
||||||
@@ -25,6 +26,8 @@ export const PageOutlet: React.FC = () => {
|
|||||||
return <ImportWorksPage />;
|
return <ImportWorksPage />;
|
||||||
case 'objects':
|
case 'objects':
|
||||||
return <ObjectsPage />;
|
return <ObjectsPage />;
|
||||||
|
case 'works':
|
||||||
|
return <WorksPage />;
|
||||||
default:
|
default:
|
||||||
return <SectionPlaceholder />;
|
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