feat: port stroy-bot-import-works as import page
Moves estimate work import into the app as ?page=import&projectId=. Behaviour is kept 1:1 with the legacy web part: project lookup with UniqueId0 check, project objects query, works paging by 200 with the progress text, 250 ms debounced search over the same fields, 100 rows per page, select-all over all filtered not-imported works, duplicate protection via cree9_comment, the same Dataverse POST body, import into the selected object and import with object creation (find or create root and child in «Объекты», cached per pair), toasts and all texts. The object picker still shows only the first 100 matches (legacy bug). HTTP-response errors are detected by their status field instead of instanceof HttpError, which does not survive the ES5 build target. Deviations are listed in docs/MIGRATION-CHECKLIST.md section 6. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -197,6 +197,13 @@
|
|||||||
|
|
||||||
🐞 Пагинация в модалке объектов не выводится (видны только первые 100).
|
🐞 Пагинация в модалке объектов не выводится (видны только первые 100).
|
||||||
|
|
||||||
|
**Сознательные отличия от import-works:**
|
||||||
|
- Адрес Dataverse берётся из настройки «URL Dataverse», адрес сайта со списками — из «URL сайта SharePoint», API — из «Базовый URL API» (значения по умолчанию прежние; раньше Dataverse был захардкожен).
|
||||||
|
- Если объект выбрать до окончания загрузки работ, загрузка продолжается (раньше перерисовка показывала «Работы не найдены», пока загрузка не заканчивалась).
|
||||||
|
- Клик по ячейке с флажком вне самого флажка строку не переключает (флажок ui-kit обёрнут в label — иначе было бы двойное переключение). Клик по остальной части строки работает как раньше.
|
||||||
|
- Тосты при нескольких подряд показываются стопкой, а не друг поверх друга.
|
||||||
|
- Окно «Выбор объекта» — `Dialog` из ui-kit (у крестика появилась подпись «Закрыть»); поле «Объект» доступно с клавиатуры.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. Ресурсы проекта (stroy-bot-project-assigner) — `?page=resources&projectId=`
|
## 7. Ресурсы проекта (stroy-bot-project-assigner) — `?page=resources&projectId=`
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Button } from '@tnsu/ui-kit';
|
||||||
|
import { IMPORT_TEXTS } from './texts';
|
||||||
|
import styles from './ImportWorksPage.module.scss';
|
||||||
|
|
||||||
|
const TEXTS = IMPORT_TEXTS.importBar;
|
||||||
|
|
||||||
|
/** Действия импорта (из useWorkImport). */
|
||||||
|
export interface IImportBarActions {
|
||||||
|
isImporting: boolean;
|
||||||
|
importIntoSelectedObject: () => void;
|
||||||
|
importWithObjects: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
selectedCount: number;
|
||||||
|
actions: IImportBarActions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* «Выбрано: N» и кнопки импорта. Во время импорта блокируется только «Импортировать»
|
||||||
|
* (с текстом «Импорт...») — как в старом коде; повторный запуск отсекает useWorkImport.
|
||||||
|
*/
|
||||||
|
export const ImportBar: React.FC<Props> = ({ selectedCount, actions }) => (
|
||||||
|
<div className={styles.importBar}>
|
||||||
|
<span className={styles.selectedCount}>{TEXTS.selected(selectedCount)}</span>
|
||||||
|
<Button variant="accent" onClick={actions.importWithObjects}>
|
||||||
|
{TEXTS.importWithObjects}
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" onClick={actions.importIntoSelectedObject} disabled={actions.isImporting}>
|
||||||
|
{actions.isImporting ? TEXTS.importing : TEXTS.import}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Icon } from '@tnsu/ui-kit';
|
||||||
|
import { IMPORT_TEXTS } from './texts';
|
||||||
|
import styles from './ImportWorksPage.module.scss';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Шапка «ИМПОРТ РАБОТ» + название проекта. */
|
||||||
|
export const ImportHeader: React.FC<Props> = ({ title }) => (
|
||||||
|
<div className={styles.header}>
|
||||||
|
<span className={styles.headerIcon} aria-hidden="true">
|
||||||
|
<Icon name="download" />
|
||||||
|
</span>
|
||||||
|
<div className={styles.headerText}>
|
||||||
|
<span className={styles.headerLabel}>{IMPORT_TEXTS.header.label}</span>
|
||||||
|
<span className={styles.headerTitle}>{title}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { IToast } from './useToasts';
|
||||||
|
import styles from './ImportWorksPage.module.scss';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
toasts: IToast[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Всплывающие сообщения об итогах импорта внизу страницы. */
|
||||||
|
export const ImportToasts: React.FC<Props> = ({ toasts }) => {
|
||||||
|
if (toasts.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div className={styles.toasts} role="status" aria-live="polite">
|
||||||
|
{toasts.map((toast) => {
|
||||||
|
const typeClass = toast.type === 'success' ? styles.toastSuccess : styles.toastError;
|
||||||
|
const leavingClass = toast.isLeaving ? styles.toastLeaving : '';
|
||||||
|
return (
|
||||||
|
<div key={toast.id} className={`${styles.toast} ${typeClass} ${leavingClass}`}>
|
||||||
|
{toast.message}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,484 @@
|
|||||||
|
@import '../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
// Размеры из stroy-bot-import-works: колонки таблицы, высота списка, поля выбора объекта.
|
||||||
|
$col-check: 36px;
|
||||||
|
$col-number: 44px;
|
||||||
|
$col-code: 130px;
|
||||||
|
$col-unit: 100px;
|
||||||
|
$col-volume: 120px;
|
||||||
|
$table-max-height: 400px;
|
||||||
|
$picker-height: 40px;
|
||||||
|
$object-list-max-height: 50vh;
|
||||||
|
$header-icon-size: 44px;
|
||||||
|
|
||||||
|
.container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
background: $white;
|
||||||
|
border: 1px solid $gray-200;
|
||||||
|
border-radius: $r-lg;
|
||||||
|
box-shadow: $sh-2;
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: $fs-body;
|
||||||
|
color: $gray-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Шапка ───────────────────────────────────────────────────────────────────
|
||||||
|
.header {
|
||||||
|
@include page-header;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $sp-3;
|
||||||
|
border-radius: $r-lg $r-lg 0 0;
|
||||||
|
color: $white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerIcon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: $header-icon-size;
|
||||||
|
height: $header-icon-size;
|
||||||
|
background: rgba($gold-400, 0.18);
|
||||||
|
border: 1px solid rgba($gold-400, 0.35);
|
||||||
|
border-radius: $r-md;
|
||||||
|
color: $gold-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerText {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerLabel {
|
||||||
|
font-size: $fs-overline;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 1.6px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerTitle {
|
||||||
|
font-family: $font-serif;
|
||||||
|
font-size: 18px; // между заголовком формы и секции — как в старом web part
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Состояния ───────────────────────────────────────────────────────────────
|
||||||
|
.loading {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: $sp-8 $sp-5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
margin: $sp-5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Секции ──────────────────────────────────────────────────────────────────
|
||||||
|
.section {
|
||||||
|
padding: $sp-4 $sp-5;
|
||||||
|
border-bottom: 1px solid $gray-200;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: $sp-3;
|
||||||
|
margin-bottom: $sp-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionLabel {
|
||||||
|
margin-bottom: $sp-3;
|
||||||
|
font-size: $fs-overline;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 1.4px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: $navy-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionHeader .sectionLabel {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchRow {
|
||||||
|
padding-bottom: $sp-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Поле выбора объекта ─────────────────────────────────────────────────────
|
||||||
|
.pickerField {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $sp-2;
|
||||||
|
width: 100%;
|
||||||
|
height: $picker-height;
|
||||||
|
padding: 0 $sp-3;
|
||||||
|
background: $white;
|
||||||
|
border: 1.5px solid $gray-300;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: $fs-body;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: $gold-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include input-focus-gold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pickerValue {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: $gray-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pickerPlaceholder {
|
||||||
|
color: $gray-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pickerClear {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
padding: 0;
|
||||||
|
background: $gray-300;
|
||||||
|
border: 0;
|
||||||
|
border-radius: $r-full;
|
||||||
|
color: $white;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $gray-500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pickerChevron {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Окно выбора объекта ─────────────────────────────────────────────────────
|
||||||
|
.objectList {
|
||||||
|
max-height: $object-list-max-height;
|
||||||
|
margin-top: $sp-3;
|
||||||
|
overflow-y: auto;
|
||||||
|
border-top: 1px solid $gray-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.objectItem {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: $sp-3 $sp-4;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 1px solid $gray-100;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: $fs-body;
|
||||||
|
line-height: 1.4;
|
||||||
|
text-align: left;
|
||||||
|
color: $gray-900;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $navy-50;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.listMessage {
|
||||||
|
padding: $sp-6 $sp-4;
|
||||||
|
text-align: center;
|
||||||
|
font-size: $fs-body;
|
||||||
|
color: $gray-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Таблица работ ───────────────────────────────────────────────────────────
|
||||||
|
.tableHeader,
|
||||||
|
.tableRow {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: $col-check $col-number 1fr $col-code $col-unit $col-volume;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 $sp-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableHeader {
|
||||||
|
min-height: 36px;
|
||||||
|
background: $gold-50;
|
||||||
|
border: 1px solid $gray-200;
|
||||||
|
border-bottom: 2px solid $gold-300;
|
||||||
|
border-radius: $r-sm $r-sm 0 0;
|
||||||
|
font-size: $fs-overline;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 1.2px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: $gold-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableBody {
|
||||||
|
max-height: $table-max-height;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid $gray-200;
|
||||||
|
border-top: 0;
|
||||||
|
border-radius: 0 0 $r-sm $r-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableRow {
|
||||||
|
min-height: 44px;
|
||||||
|
border-bottom: 1px solid $gray-200;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:nth-child(even) {
|
||||||
|
background: $gray-50;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $gold-50;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableRowImported,
|
||||||
|
.tableRowImported:nth-child(even),
|
||||||
|
.tableRowImported:hover {
|
||||||
|
background: $green-50;
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colCheck {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colNumber {
|
||||||
|
font-size: $fs-label;
|
||||||
|
text-align: center;
|
||||||
|
color: $gray-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colTitle {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
padding: $sp-2 $sp-3 $sp-2 $sp-1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workName {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: $sp-1;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workSubtitle {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: $fs-caption;
|
||||||
|
color: $gray-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colCode {
|
||||||
|
padding: $sp-2 $sp-1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-family: 'Consolas', 'Courier New', monospace;
|
||||||
|
font-size: $fs-label;
|
||||||
|
color: $gray-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colUnit {
|
||||||
|
padding: $sp-2;
|
||||||
|
text-align: center;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
color: $gray-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colVolume {
|
||||||
|
padding: $sp-2;
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
font-weight: 500;
|
||||||
|
color: $gray-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Пагинация ───────────────────────────────────────────────────────────────
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: $sp-1;
|
||||||
|
padding: $sp-3 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageEllipsis {
|
||||||
|
padding: 0 $sp-1;
|
||||||
|
color: $gray-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Панель импорта и подсказка ──────────────────────────────────────────────
|
||||||
|
.importBar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: $sp-3;
|
||||||
|
margin-top: $sp-3;
|
||||||
|
padding: $sp-2 $sp-4;
|
||||||
|
background: $navy-50;
|
||||||
|
border: 1px solid $gray-200;
|
||||||
|
border-left: 3px solid $gold-400;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectedCount {
|
||||||
|
margin-right: auto;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $navy-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.structureHint {
|
||||||
|
margin-top: $sp-2;
|
||||||
|
padding: $sp-2 $sp-3;
|
||||||
|
background: $gold-50;
|
||||||
|
border: 1px solid $gold-300;
|
||||||
|
border-left: 3px solid $gold-400;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
font-size: $fs-label;
|
||||||
|
}
|
||||||
|
|
||||||
|
.structureHintLabel {
|
||||||
|
margin-bottom: $sp-1;
|
||||||
|
font-size: $fs-caption;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 1.2px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: $gold-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.structureHintRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $sp-1;
|
||||||
|
padding: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.structureHintArrow {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: $gold-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.structureHintItem {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: $gray-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.structureHintMore {
|
||||||
|
margin-top: 3px;
|
||||||
|
padding-left: $sp-4;
|
||||||
|
font-size: $fs-caption;
|
||||||
|
font-style: italic;
|
||||||
|
color: $gold-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Тосты ───────────────────────────────────────────────────────────────────
|
||||||
|
.toasts {
|
||||||
|
position: absolute;
|
||||||
|
bottom: $sp-4;
|
||||||
|
left: 50%;
|
||||||
|
z-index: 100;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: $sp-2;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
padding: $sp-2 $sp-5;
|
||||||
|
border-radius: $r-md;
|
||||||
|
box-shadow: $sh-3;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: $white;
|
||||||
|
animation: toast-in 0.25s ease;
|
||||||
|
transition: opacity 0.25s ease, transform 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toastSuccess {
|
||||||
|
background: $green-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toastError {
|
||||||
|
background: $red-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toastLeaving {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes toast-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(12px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Адаптивность ────────────────────────────────────────────────────────────
|
||||||
|
@include below($bp-md) {
|
||||||
|
.tableHeader,
|
||||||
|
.tableRow {
|
||||||
|
grid-template-columns: $col-check 1fr 80px 90px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colNumber,
|
||||||
|
.colCode {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@include below($bp-sm) {
|
||||||
|
.tableHeader,
|
||||||
|
.tableRow {
|
||||||
|
grid-template-columns: 32px 1fr 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colUnit {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Alert, Spinner } from '@tnsu/ui-kit';
|
||||||
|
import { useRouter } from '../../app/routing/RouterContext';
|
||||||
|
import { useImportProject } from './useImportProject';
|
||||||
|
import { ImportHeader } from './ImportHeader';
|
||||||
|
import { ImportWorkspace } from './ImportWorkspace';
|
||||||
|
import { IMPORT_TEXTS } from './texts';
|
||||||
|
import styles from './ImportWorksPage.module.scss';
|
||||||
|
|
||||||
|
/** Импорт работ из сметы (бывший stroy-bot-import-works): `?page=import&projectId=`. */
|
||||||
|
export const ImportWorksPage: React.FC = () => {
|
||||||
|
const { route } = useRouter();
|
||||||
|
const { headerTitle, content } = useImportProject(route.projectId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<ImportHeader title={headerTitle} />
|
||||||
|
{content.kind === 'loading' && (
|
||||||
|
<div className={styles.loading}>
|
||||||
|
<Spinner label={content.text} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{content.kind === 'error' && (
|
||||||
|
<div className={styles.message}>
|
||||||
|
<Alert variant="err">{content.message}</Alert>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{content.kind === 'notBound' && (
|
||||||
|
<div className={styles.message}>
|
||||||
|
<Alert variant="warn">{IMPORT_TEXTS.notBound}</Alert>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{content.kind === 'ready' && (
|
||||||
|
<ImportWorkspace key={content.project.id} project={content.project} initialObjects={content.objects} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Alert, Spinner } from '@tnsu/ui-kit';
|
||||||
|
import { IImportObject, IImportOutcome, IImportProject } from './importTypes';
|
||||||
|
import { useEstimateWorks } from './useEstimateWorks';
|
||||||
|
import { useWorksBrowser } from './useWorksBrowser';
|
||||||
|
import { useWorkImport } from './useWorkImport';
|
||||||
|
import { useToasts } from './useToasts';
|
||||||
|
import { ObjectPickerField } from './ObjectPickerField';
|
||||||
|
import { ObjectPickerDialog } from './ObjectPickerDialog';
|
||||||
|
import { WorksSection } from './WorksSection';
|
||||||
|
import { ImportToasts } from './ImportToasts';
|
||||||
|
import styles from './ImportWorksPage.module.scss';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
project: IImportProject;
|
||||||
|
initialObjects: IImportObject[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Рабочая область импорта: выбор объекта, работы сметы, импорт. */
|
||||||
|
export const ImportWorkspace: React.FC<Props> = ({ project, initialObjects }) => {
|
||||||
|
// Объекты дополняются созданными при «Импорт с созданием объекта» — поэтому свой state.
|
||||||
|
const [objects, setObjects] = React.useState<IImportObject[]>(initialObjects);
|
||||||
|
const [selectedObjectId, setSelectedObjectId] = React.useState<number | undefined>(undefined);
|
||||||
|
const [isPickerOpen, setIsPickerOpen] = React.useState<boolean>(false);
|
||||||
|
const estimate = useEstimateWorks(project);
|
||||||
|
const browser = useWorksBrowser(estimate.works, estimate.imported);
|
||||||
|
const { toasts, showToast } = useToasts();
|
||||||
|
|
||||||
|
const onImported = (entries: IImportOutcome['imported']): void => {
|
||||||
|
estimate.markImported(entries);
|
||||||
|
browser.clearSelection();
|
||||||
|
};
|
||||||
|
|
||||||
|
const importer = useWorkImport({
|
||||||
|
project,
|
||||||
|
objects,
|
||||||
|
onObjectsChange: setObjects,
|
||||||
|
selectedObjectId,
|
||||||
|
works: estimate.works,
|
||||||
|
imported: estimate.imported,
|
||||||
|
selected: browser.selected,
|
||||||
|
onImported,
|
||||||
|
showToast
|
||||||
|
});
|
||||||
|
|
||||||
|
// Как в старом коде: выбор объекта перечитывает список уже импортированных работ.
|
||||||
|
const selectObject = (objectId: number): void => {
|
||||||
|
setIsPickerOpen(false);
|
||||||
|
setSelectedObjectId(objectId);
|
||||||
|
estimate.reloadImported();
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedObject = objects.filter((object) => object.id === selectedObjectId)[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ObjectPickerField
|
||||||
|
selectedObject={selectedObject}
|
||||||
|
onOpen={() => setIsPickerOpen(true)}
|
||||||
|
onClear={() => setSelectedObjectId(undefined)}
|
||||||
|
/>
|
||||||
|
{isPickerOpen && (
|
||||||
|
<ObjectPickerDialog objects={objects} onSelect={selectObject} onDismiss={() => setIsPickerOpen(false)} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{estimate.status.kind === 'loading' && (
|
||||||
|
<div className={styles.loading}>
|
||||||
|
<Spinner label={estimate.status.text} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{estimate.status.kind === 'error' && (
|
||||||
|
<div className={styles.message}>
|
||||||
|
<Alert variant="err">{estimate.status.message}</Alert>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{estimate.status.kind === 'ready' && (
|
||||||
|
<WorksSection
|
||||||
|
works={estimate.works}
|
||||||
|
imported={estimate.imported}
|
||||||
|
objects={objects}
|
||||||
|
browser={browser}
|
||||||
|
importer={importer}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ImportToasts toasts={toasts} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Dialog, TextInput } from '@tnsu/ui-kit';
|
||||||
|
import { IImportObject } from './importTypes';
|
||||||
|
import { filterObjects } from './importRules';
|
||||||
|
import { IMPORT_TEXTS } from './texts';
|
||||||
|
import styles from './ImportWorksPage.module.scss';
|
||||||
|
|
||||||
|
const TEXTS = IMPORT_TEXTS.objectPicker;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
objects: IImportObject[];
|
||||||
|
onSelect: (objectId: number) => void;
|
||||||
|
onDismiss: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Окно «Выбор объекта». Монтируется при открытии, поэтому поиск каждый раз пустой — как раньше.
|
||||||
|
* 🐞 Видны только первые 100 совпадений (пагинации в окне нет) — перенесено как есть.
|
||||||
|
*/
|
||||||
|
export const ObjectPickerDialog: React.FC<Props> = ({ objects, onSelect, onDismiss }) => {
|
||||||
|
const [search, setSearch] = React.useState<string>('');
|
||||||
|
const visibleObjects = filterObjects(objects, search);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open title={TEXTS.dialogTitle} onDismiss={onDismiss}>
|
||||||
|
<TextInput value={search} onChange={setSearch} placeholder={TEXTS.search} ariaLabel={TEXTS.search} autoFocus />
|
||||||
|
<div className={styles.objectList}>
|
||||||
|
{visibleObjects.length === 0 && <div className={styles.listMessage}>{TEXTS.nothingFound}</div>}
|
||||||
|
{visibleObjects.map((object) => (
|
||||||
|
<button key={object.id} type="button" className={styles.objectItem} onClick={() => onSelect(object.id)}>
|
||||||
|
{object.fullPath}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Icon } from '@tnsu/ui-kit';
|
||||||
|
import { IImportObject } from './importTypes';
|
||||||
|
import { IMPORT_TEXTS } from './texts';
|
||||||
|
import styles from './ImportWorksPage.module.scss';
|
||||||
|
|
||||||
|
const TEXTS = IMPORT_TEXTS.objectPicker;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
selectedObject: IImportObject | undefined;
|
||||||
|
onOpen: () => void;
|
||||||
|
onClear: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Поле «Объект»: путь выбранного объекта или «Выберите объект...», × — сбросить выбор. */
|
||||||
|
export const ObjectPickerField: React.FC<Props> = ({ selectedObject, onOpen, onClear }) => {
|
||||||
|
const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>): void => {
|
||||||
|
if (event.target !== event.currentTarget) return;
|
||||||
|
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||||
|
event.preventDefault();
|
||||||
|
onOpen();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onClearClick = (event: React.MouseEvent<HTMLButtonElement>): void => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onClear();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.section}>
|
||||||
|
<div className={styles.sectionLabel}>{TEXTS.label}</div>
|
||||||
|
<div className={styles.pickerField} role="button" tabIndex={0} onClick={onOpen} onKeyDown={onKeyDown}>
|
||||||
|
<span className={selectedObject ? styles.pickerValue : `${styles.pickerValue} ${styles.pickerPlaceholder}`}>
|
||||||
|
{selectedObject ? selectedObject.fullPath : TEXTS.placeholder}
|
||||||
|
</span>
|
||||||
|
{selectedObject && (
|
||||||
|
<button type="button" className={styles.pickerClear} title={TEXTS.clear} onClick={onClearClick}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<span className={styles.pickerChevron} aria-hidden="true">
|
||||||
|
<Icon name="chevron-down" size="sm" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { IStructureHint } from './importTypes';
|
||||||
|
import { IMPORT_TEXTS } from './texts';
|
||||||
|
import styles from './ImportWorksPage.module.scss';
|
||||||
|
|
||||||
|
const TEXTS = IMPORT_TEXTS.structureHint;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
hint: IStructureHint;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** «Объекты из сметы:» — какие объекты затронет «Импорт с созданием объекта». */
|
||||||
|
export const StructureHint: React.FC<Props> = ({ hint }) => {
|
||||||
|
if (hint.visible.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div className={styles.structureHint}>
|
||||||
|
<div className={styles.structureHintLabel}>{TEXTS.label}</div>
|
||||||
|
{hint.visible.map((label) => (
|
||||||
|
<div key={label} className={styles.structureHintRow}>
|
||||||
|
<span className={styles.structureHintArrow}>{TEXTS.arrow}</span>
|
||||||
|
<span className={styles.structureHintItem} title={label}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{hint.rest > 0 && <div className={styles.structureHintMore}>{TEXTS.more(hint.rest)}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Badge, Checkbox } from '@tnsu/ui-kit';
|
||||||
|
import { IEstimateWork } from './importTypes';
|
||||||
|
import { formatVolume, workSubtitle } from './importRules';
|
||||||
|
import { IMPORT_TEXTS } from './texts';
|
||||||
|
import styles from './ImportWorksPage.module.scss';
|
||||||
|
|
||||||
|
const TEXTS = IMPORT_TEXTS.works;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
work: IEstimateWork;
|
||||||
|
rowNumber: number;
|
||||||
|
isImported: boolean;
|
||||||
|
/** Название объекта, куда работа уже импортирована ('' — неизвестно). */
|
||||||
|
importedObjectTitle: string;
|
||||||
|
isSelected: boolean;
|
||||||
|
onToggle: (workId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Строка работы. Клик по строке переключает флажок; импортированные работы выбрать нельзя. */
|
||||||
|
export const WorkRow: React.FC<Props> = ({ work, rowNumber, isImported, importedObjectTitle, isSelected, onToggle }) => {
|
||||||
|
const subtitle = workSubtitle(work);
|
||||||
|
const className = isImported ? `${styles.tableRow} ${styles.tableRowImported}` : styles.tableRow;
|
||||||
|
|
||||||
|
const onRowClick = (): void => {
|
||||||
|
if (!isImported) onToggle(work.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className} onClick={onRowClick}>
|
||||||
|
{/* Клик по флажку обрабатывает сам флажок — не даём ему всплыть до строки и переключить второй раз. */}
|
||||||
|
<div
|
||||||
|
className={styles.colCheck}
|
||||||
|
title={isImported ? TEXTS.alreadyImported : undefined}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={!isImported && isSelected}
|
||||||
|
disabled={isImported}
|
||||||
|
onChange={() => onToggle(work.id)}
|
||||||
|
ariaLabel={work.name || work.id}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.colNumber}>{rowNumber}</div>
|
||||||
|
<div className={styles.colTitle}>
|
||||||
|
<span className={styles.workName}>
|
||||||
|
{work.name}
|
||||||
|
{importedObjectTitle && (
|
||||||
|
<Badge variant="info" size="sm">
|
||||||
|
{importedObjectTitle}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{isImported && (
|
||||||
|
<Badge variant="success" size="sm">
|
||||||
|
{TEXTS.imported}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{subtitle && <span className={styles.workSubtitle}>{subtitle}</span>}
|
||||||
|
</div>
|
||||||
|
<div className={styles.colCode}>{work.codeSnb || ''}</div>
|
||||||
|
<div className={styles.colUnit}>{work.unit || ''}</div>
|
||||||
|
<div className={styles.colVolume}>{formatVolume(work.volume)}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Button } from '@tnsu/ui-kit';
|
||||||
|
import { buildPageRange } from './importRules';
|
||||||
|
import { IMPORT_TEXTS } from './texts';
|
||||||
|
import styles from './ImportWorksPage.module.scss';
|
||||||
|
|
||||||
|
const TEXTS = IMPORT_TEXTS.works;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
page: number;
|
||||||
|
totalPages: number;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** «<», 1 … текущая ±3 … последняя, «>». Одна страница — пагинации нет. */
|
||||||
|
export const WorksPagination: React.FC<Props> = ({ page, totalPages, onPageChange }) => {
|
||||||
|
const range = buildPageRange(page, totalPages);
|
||||||
|
if (range.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.pagination}>
|
||||||
|
<Button variant="secondary" size="sm" disabled={page === 1} onClick={() => onPageChange(page - 1)}>
|
||||||
|
{TEXTS.prevPage}
|
||||||
|
</Button>
|
||||||
|
{range.map((item, index) =>
|
||||||
|
item === 'ellipsis' ? (
|
||||||
|
// Многоточий может быть два — ключ по позиции.
|
||||||
|
<span key={`ellipsis-${index}`} className={styles.pageEllipsis}>
|
||||||
|
{TEXTS.ellipsis}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
key={item}
|
||||||
|
variant={item === page ? 'primary' : 'secondary'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onPageChange(item)}
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<Button variant="secondary" size="sm" disabled={page === totalPages} onClick={() => onPageChange(page + 1)}>
|
||||||
|
{TEXTS.nextPage}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Button, TextInput } from '@tnsu/ui-kit';
|
||||||
|
import { EmptyState } from '../../shared/components/EmptyState';
|
||||||
|
import { IEstimateWork, IImportObject, ImportedWorks } from './importTypes';
|
||||||
|
import { buildStructureHint, worksCountLabel } from './importRules';
|
||||||
|
import { IWorksBrowser } from './useWorksBrowser';
|
||||||
|
import { WorksTable } from './WorksTable';
|
||||||
|
import { WorksPagination } from './WorksPagination';
|
||||||
|
import { ImportBar, IImportBarActions } from './ImportBar';
|
||||||
|
import { StructureHint } from './StructureHint';
|
||||||
|
import { IMPORT_TEXTS } from './texts';
|
||||||
|
import styles from './ImportWorksPage.module.scss';
|
||||||
|
|
||||||
|
const TEXTS = IMPORT_TEXTS.works;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
works: IEstimateWork[];
|
||||||
|
imported: ImportedWorks;
|
||||||
|
objects: IImportObject[];
|
||||||
|
browser: IWorksBrowser;
|
||||||
|
importer: IImportBarActions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Работы сметы: счётчик, «выделить всё», поиск, таблица, пагинация, панель импорта и подсказка. */
|
||||||
|
export const WorksSection: React.FC<Props> = ({ works, imported, objects, browser, importer }) => {
|
||||||
|
if (works.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className={styles.section}>
|
||||||
|
<EmptyState icon="document" title={TEXTS.empty} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const countLabel = worksCountLabel(
|
||||||
|
browser.query,
|
||||||
|
browser.filtered.length,
|
||||||
|
works.length,
|
||||||
|
browser.page,
|
||||||
|
browser.totalPages
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.section}>
|
||||||
|
<div className={styles.sectionHeader}>
|
||||||
|
<div className={styles.sectionLabel}>{countLabel}</div>
|
||||||
|
<Button variant="secondary" size="sm" onClick={browser.toggleAll}>
|
||||||
|
{browser.isAllSelected ? TEXTS.deselectAll : TEXTS.selectAll}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className={styles.searchRow}>
|
||||||
|
<TextInput
|
||||||
|
value={browser.searchText}
|
||||||
|
onChange={browser.onSearchTextChange}
|
||||||
|
placeholder={TEXTS.searchPlaceholder}
|
||||||
|
ariaLabel={TEXTS.searchPlaceholder}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<WorksTable
|
||||||
|
works={browser.pageWorks}
|
||||||
|
pageOffset={browser.pageOffset}
|
||||||
|
imported={imported}
|
||||||
|
objects={objects}
|
||||||
|
selected={browser.selected}
|
||||||
|
onToggle={browser.toggleWork}
|
||||||
|
/>
|
||||||
|
<WorksPagination page={browser.page} totalPages={browser.totalPages} onPageChange={browser.goToPage} />
|
||||||
|
{browser.selected.size > 0 && <ImportBar selectedCount={browser.selected.size} actions={importer} />}
|
||||||
|
<StructureHint hint={buildStructureHint(works, browser.selected)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { IEstimateWork, IImportObject, ImportedWorks } from './importTypes';
|
||||||
|
import { importedObjectTitle } from './importRules';
|
||||||
|
import { WorkRow } from './WorkRow';
|
||||||
|
import { IMPORT_TEXTS } from './texts';
|
||||||
|
import styles from './ImportWorksPage.module.scss';
|
||||||
|
|
||||||
|
const COLUMNS = IMPORT_TEXTS.works.columns;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Работы текущей страницы. */
|
||||||
|
works: IEstimateWork[];
|
||||||
|
pageOffset: number;
|
||||||
|
imported: ImportedWorks;
|
||||||
|
objects: IImportObject[];
|
||||||
|
selected: Set<string>;
|
||||||
|
onToggle: (workId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Таблица работ: #, Наименование, Код СНБ, Ед. изм., Объём. */
|
||||||
|
export const WorksTable: React.FC<Props> = ({ works, pageOffset, imported, objects, selected, onToggle }) => (
|
||||||
|
<>
|
||||||
|
<div className={styles.tableHeader}>
|
||||||
|
<div className={styles.colCheck} />
|
||||||
|
<div className={styles.colNumber}>{COLUMNS.number}</div>
|
||||||
|
<div className={styles.colTitle}>{COLUMNS.name}</div>
|
||||||
|
<div className={styles.colCode}>{COLUMNS.code}</div>
|
||||||
|
<div className={styles.colUnit}>{COLUMNS.unit}</div>
|
||||||
|
<div className={styles.colVolume}>{COLUMNS.volume}</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.tableBody}>
|
||||||
|
{works.length === 0 && <div className={styles.listMessage}>{IMPORT_TEXTS.works.nothingFound}</div>}
|
||||||
|
{works.map((work, index) => (
|
||||||
|
<WorkRow
|
||||||
|
key={work.id}
|
||||||
|
work={work}
|
||||||
|
rowNumber={pageOffset + index + 1}
|
||||||
|
isImported={imported.has(work.id)}
|
||||||
|
importedObjectTitle={importedObjectTitle(work.id, imported, objects)}
|
||||||
|
isSelected={selected.has(work.id)}
|
||||||
|
onToggle={onToggle}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
import {
|
||||||
|
createObject,
|
||||||
|
estimateWorksUrl,
|
||||||
|
fetchEstimateWorks,
|
||||||
|
fetchImportedWorks,
|
||||||
|
fetchImportProject,
|
||||||
|
fetchProjectObjects,
|
||||||
|
importedWorksUrl,
|
||||||
|
postImportedWork,
|
||||||
|
projectItemUrl,
|
||||||
|
projectObjectsUrl
|
||||||
|
} from './importRepository';
|
||||||
|
import { importIntoObject, importWithObjectCreation } from './importService';
|
||||||
|
import { IImportObject } from './importTypes';
|
||||||
|
import { createFakeHttpClient } from '../../shared/testing/fakeHttpClient';
|
||||||
|
import { IHttpClient } from '../../services/http/IHttpClient';
|
||||||
|
|
||||||
|
const SITE = 'https://tnsukz.sharepoint.com/sites/stroy';
|
||||||
|
const DV = 'https://tansu.crm5.dynamics.com';
|
||||||
|
const API = 'https://api.tnsu.kz';
|
||||||
|
|
||||||
|
describe('адреса запросов (1:1 со stroy-bot-import-works)', () => {
|
||||||
|
it('проект по id с UniqueId0', () => {
|
||||||
|
expect(projectItemUrl(SITE, 5)).toBe(
|
||||||
|
`${SITE}/_api/web/lists/getbytitle('Проекты')/items(5)?$select=Id,Title,UniqueId0`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('объекты проекта по названию, апострофы удваиваются', () => {
|
||||||
|
expect(projectObjectsUrl(SITE, "ЖК 'Сункар'")).toBe(
|
||||||
|
`${SITE}/_api/web/lists/getbytitle('Объекты')/items` +
|
||||||
|
`?$select=Id,Title,parent/Id&$expand=parent&$filter=project eq 'ЖК ''Сункар'''&$top=5000`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('работы сметы — страница по 200', () => {
|
||||||
|
expect(estimateWorksUrl(API, 'guid-1', 3)).toBe(`${API}/Project/guid-1/works?page=3&pageSize=200`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('импортированные работы из cree9_tables', () => {
|
||||||
|
expect(importedWorksUrl(DV)).toBe(`${DV}/api/data/v9.2/cree9_tables?$select=cree9_comment,cree9_objectid&$top=5000`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fetchImportProject / fetchProjectObjects', () => {
|
||||||
|
it('неуспешный ответ — ошибка со статусом (текст «Не удалось загрузить…»)', async () => {
|
||||||
|
const sp = createFakeHttpClient([{ match: 'Проекты', response: { status: 404 } }]);
|
||||||
|
await expect(fetchImportProject(sp, SITE, 5)).rejects.toMatchObject({ status: 404 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('объекты с полными путями, по алфавиту путей', async () => {
|
||||||
|
const sp = createFakeHttpClient([
|
||||||
|
{
|
||||||
|
match: 'Объекты',
|
||||||
|
response: {
|
||||||
|
body: {
|
||||||
|
value: [
|
||||||
|
{ Id: 2, Title: 'Фундамент', parent: { Id: 1 } },
|
||||||
|
{ Id: 1, Title: 'Корпус Б' },
|
||||||
|
{ Id: 3, Title: 'Корпус А' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
const objects = await fetchProjectObjects(sp, SITE, 'Сункар');
|
||||||
|
expect(objects.map((o) => o.fullPath)).toEqual(['Корпус А', 'Корпус Б', 'Корпус Б / Фундамент']);
|
||||||
|
expect(objects[2]).toEqual({ id: 2, title: 'Фундамент', fullPath: 'Корпус Б / Фундамент', parentId: 1 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fetchEstimateWorks', () => {
|
||||||
|
it('читает все страницы по totalPages и сообщает прогресс', async () => {
|
||||||
|
const api = createFakeHttpClient([
|
||||||
|
{ match: 'page=1&', response: { body: { items: [{ id: 'a' }, { id: 'b' }], totalCount: 3, totalPages: 2 } } },
|
||||||
|
{ match: 'page=2&', response: { body: { items: [{ id: 'c' }], totalCount: 3, totalPages: 2 } } }
|
||||||
|
]);
|
||||||
|
const progress: string[] = [];
|
||||||
|
const works = await fetchEstimateWorks(api, API, 'guid-1', (loaded, total) => progress.push(`${loaded}/${total}`));
|
||||||
|
|
||||||
|
expect(works.map((w) => w.id)).toEqual(['a', 'b', 'c']);
|
||||||
|
expect(progress).toEqual(['2/3', '3/3']);
|
||||||
|
expect(api.requests.map((r) => r.url)).toEqual([
|
||||||
|
`${API}/Project/guid-1/works?page=1&pageSize=200`,
|
||||||
|
`${API}/Project/guid-1/works?page=2&pageSize=200`
|
||||||
|
]);
|
||||||
|
expect(api.requests[0].request).toEqual({ headers: { Accept: 'application/json' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('без totalPages — число страниц по totalCount / 200', async () => {
|
||||||
|
const api = createFakeHttpClient([
|
||||||
|
{ match: 'page=1&', response: { body: { items: [], totalCount: 401 } } },
|
||||||
|
{ match: 'page=2&', response: { body: { items: [], totalCount: 401 } } },
|
||||||
|
{ match: 'page=3&', response: { body: { items: [], totalCount: 401 } } }
|
||||||
|
]);
|
||||||
|
await fetchEstimateWorks(api, API, 'g', () => undefined);
|
||||||
|
expect(api.requests).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ошибка API — ошибка со статусом', async () => {
|
||||||
|
const api = createFakeHttpClient([{ match: 'works', response: { status: 500 } }]);
|
||||||
|
await expect(fetchEstimateWorks(api, API, 'g', () => undefined)).rejects.toMatchObject({ status: 500 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fetchImportedWorks', () => {
|
||||||
|
it('id работы из cree9_comment → objectId; пустые и нечисловые пропускаются', async () => {
|
||||||
|
const dv = createFakeHttpClient([
|
||||||
|
{
|
||||||
|
match: 'cree9_tables',
|
||||||
|
response: {
|
||||||
|
body: {
|
||||||
|
value: [
|
||||||
|
{ cree9_comment: 'w1', cree9_objectid: '12' },
|
||||||
|
{ cree9_comment: '', cree9_objectid: '13' },
|
||||||
|
{ cree9_comment: 'w3', cree9_objectid: 'abc' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
const imported = await fetchImportedWorks(dv, DV);
|
||||||
|
expect(Array.from(imported.entries())).toEqual([['w1', 12]]);
|
||||||
|
expect(dv.requests[0].request).toEqual({
|
||||||
|
method: 'GET',
|
||||||
|
headers: { Accept: 'application/json', 'OData-MaxVersion': '4.0', 'OData-Version': '4.0' }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ошибка — пустой список, без исключения', async () => {
|
||||||
|
const dv = createFakeHttpClient([{ match: 'cree9_tables', response: { status: 500 } }]);
|
||||||
|
expect((await fetchImportedWorks(dv, DV)).size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('postImportedWork', () => {
|
||||||
|
it('POST cree9_tables: название до 100 символов, объём, ед. изм., objectId строкой, id работы в comment', async () => {
|
||||||
|
const dv = createFakeHttpClient([{ match: 'cree9_tables', response: { status: 204 } }]);
|
||||||
|
const longName = 'Б'.repeat(150);
|
||||||
|
await postImportedWork(dv, DV, { id: 'w1', name: longName, volume: 12.5, unit: 'м3' }, 7);
|
||||||
|
|
||||||
|
expect(dv.requests[0].url).toBe(`${DV}/api/data/v9.2/cree9_tables`);
|
||||||
|
expect(dv.requests[0].request.method).toBe('POST');
|
||||||
|
expect(dv.requests[0].request.headers).toEqual({
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'OData-MaxVersion': '4.0',
|
||||||
|
'OData-Version': '4.0'
|
||||||
|
});
|
||||||
|
expect(dv.requests[0].request.body).toBe(
|
||||||
|
JSON.stringify({
|
||||||
|
cree9_title: 'Б'.repeat(100),
|
||||||
|
cree9_volume: 12.5,
|
||||||
|
cree9_unit: 'м3',
|
||||||
|
cree9_objectid: '7',
|
||||||
|
cree9_comment: 'w1'
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('без объёма — явный null, без названия и ед. изм. — пустые строки', async () => {
|
||||||
|
const dv = createFakeHttpClient([{ match: 'cree9_tables', response: { status: 204 } }]);
|
||||||
|
await postImportedWork(dv, DV, { id: 'w2' }, 7);
|
||||||
|
expect(dv.requests[0].request.body).toBe(
|
||||||
|
'{"cree9_title":"","cree9_volume":null,"cree9_unit":"","cree9_objectid":"7","cree9_comment":"w2"}'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createObject', () => {
|
||||||
|
it('POST «Объекты» nometadata: Title, project = название проекта, parentId', async () => {
|
||||||
|
const sp = createFakeHttpClient([{ match: 'Объекты', response: { status: 201, body: { Id: 40 } } }]);
|
||||||
|
expect(await createObject(sp, SITE, { title: 'Раздел', projectTitle: 'Сункар', parentId: 3 })).toBe(40);
|
||||||
|
expect(sp.requests[0]).toEqual({
|
||||||
|
url: `${SITE}/_api/web/lists/getbytitle('Объекты')/items`,
|
||||||
|
request: {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json;odata=nometadata',
|
||||||
|
'Content-Type': 'application/json;odata=nometadata'
|
||||||
|
},
|
||||||
|
body: '{"Title":"Раздел","project":"Сункар","parentId":3}'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('корневой объект — без parentId; ошибка — «Не удалось создать объект: …»', async () => {
|
||||||
|
const ok = createFakeHttpClient([{ match: 'Объекты', response: { status: 201, body: { Id: 41 } } }]);
|
||||||
|
await createObject(ok, SITE, { title: 'Корпус', projectTitle: 'Сункар', parentId: undefined });
|
||||||
|
expect(ok.requests[0].request.body).toBe('{"Title":"Корпус","project":"Сункар"}');
|
||||||
|
|
||||||
|
const failing = createFakeHttpClient([{ match: 'Объекты', response: { status: 400 } }]);
|
||||||
|
await expect(
|
||||||
|
createObject(failing, SITE, { title: 'Корпус', projectTitle: 'Сункар', parentId: undefined })
|
||||||
|
).rejects.toThrow('Не удалось создать объект: Корпус');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('importIntoObject', () => {
|
||||||
|
it('ошибка одной работы не останавливает остальные', async () => {
|
||||||
|
const ok = createFakeHttpClient([{ match: 'cree9_tables', response: { status: 204 } }]);
|
||||||
|
const rejected = createFakeHttpClient([{ match: 'cree9_tables', response: { status: 400, body: 'bad' } }]);
|
||||||
|
// Работа «bad» получает ошибку Dataverse, остальные — успех.
|
||||||
|
const dataverse: IHttpClient = {
|
||||||
|
fetch: (url, request) => (String(request?.body).indexOf('"bad"') !== -1 ? rejected : ok).fetch(url, request)
|
||||||
|
};
|
||||||
|
const deps = { sharePoint: createFakeHttpClient(), dataverse, siteUrl: SITE, dataverseUrl: DV };
|
||||||
|
|
||||||
|
const outcome = await importIntoObject(deps, [{ id: 'ok1' }, { id: 'bad' }, { id: 'ok2' }], 9, []);
|
||||||
|
|
||||||
|
expect(outcome.successCount).toBe(2);
|
||||||
|
expect(outcome.errorCount).toBe(1);
|
||||||
|
expect(outcome.imported).toEqual([
|
||||||
|
{ workId: 'ok1', objectId: 9 },
|
||||||
|
{ workId: 'ok2', objectId: 9 }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('importWithObjectCreation', () => {
|
||||||
|
const existing: IImportObject[] = [{ id: 1, title: 'Корпус А', fullPath: 'Корпус А', parentId: undefined }];
|
||||||
|
|
||||||
|
it('находит корень без учёта регистра, создаёт раздел один раз на пару «объект + смета»', async () => {
|
||||||
|
const sharePoint = createFakeHttpClient([{ match: 'Объекты', response: { status: 201, body: { Id: 50 } } }]);
|
||||||
|
const dataverse = createFakeHttpClient([{ match: 'cree9_tables', response: { status: 204 } }]);
|
||||||
|
const deps = { sharePoint, dataverse, siteUrl: SITE, dataverseUrl: DV };
|
||||||
|
const works = [
|
||||||
|
{ id: 'w1', objectName: ' корпус а ', localEstimateName: 'Каркас' },
|
||||||
|
{ id: 'w2', objectName: ' корпус а ', localEstimateName: 'Каркас' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const outcome = await importWithObjectCreation(deps, works, existing, 'Сункар');
|
||||||
|
|
||||||
|
expect(sharePoint.requests).toHaveLength(1);
|
||||||
|
expect(sharePoint.requests[0].request.body).toBe('{"Title":"Каркас","project":"Сункар","parentId":1}');
|
||||||
|
expect(outcome.imported).toEqual([
|
||||||
|
{ workId: 'w1', objectId: 50 },
|
||||||
|
{ workId: 'w2', objectId: 50 }
|
||||||
|
]);
|
||||||
|
expect(outcome.objects[1]).toEqual({ id: 50, title: 'Каркас', fullPath: 'Корпус А / Каркас', parentId: 1 });
|
||||||
|
expect(existing).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('нет корня — создаёт корень и раздел; раздел с тем же названием, но другим родителем не подходит', async () => {
|
||||||
|
// Каждый POST в «Объекты» возвращает следующий Id: 60, 61, …
|
||||||
|
let nextId = 60;
|
||||||
|
const bodies: string[] = [];
|
||||||
|
const sharePoint: IHttpClient = {
|
||||||
|
fetch: (url, request) => {
|
||||||
|
bodies.push(String(request?.body));
|
||||||
|
return createFakeHttpClient([{ match: '', response: { status: 201, body: { Id: nextId++ } } }]).fetch(url);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const dataverse = createFakeHttpClient([{ match: 'cree9_tables', response: { status: 204 } }]);
|
||||||
|
const objects: IImportObject[] = [{ id: 2, title: 'Каркас', fullPath: 'Другой / Каркас', parentId: 99 }];
|
||||||
|
|
||||||
|
const outcome = await importWithObjectCreation(
|
||||||
|
{ sharePoint, dataverse, siteUrl: SITE, dataverseUrl: DV },
|
||||||
|
[{ id: 'w1', objectName: 'Корпус В', localEstimateName: 'Каркас' }],
|
||||||
|
objects,
|
||||||
|
'Сункар'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(bodies).toEqual([
|
||||||
|
'{"Title":"Корпус В","project":"Сункар"}',
|
||||||
|
'{"Title":"Каркас","project":"Сункар","parentId":60}'
|
||||||
|
]);
|
||||||
|
expect(outcome.imported).toEqual([{ workId: 'w1', objectId: 61 }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { ensureOk, IHttpClient } from '../../services/http/IHttpClient';
|
||||||
|
import { ISpCollection, listItemsUrl } from '../../shared/sharepoint/spRest';
|
||||||
|
import { dataverseUrl } from '../../shared/dataverse/dataverseRest';
|
||||||
|
import { LISTS } from '../../config/lists';
|
||||||
|
import { DV_TABLES } from '../../config/dataverse';
|
||||||
|
import {
|
||||||
|
IEstimateWork,
|
||||||
|
IEstimateWorksPage,
|
||||||
|
IImportObject,
|
||||||
|
IObjectItemResponse,
|
||||||
|
ImportedWorks,
|
||||||
|
IProjectItemResponse
|
||||||
|
} from './importTypes';
|
||||||
|
import { API_FETCH_PAGE_SIZE, apiTotalPages, buildObjectList } from './importRules';
|
||||||
|
import { IMPORT_TEXTS } from './texts';
|
||||||
|
|
||||||
|
// Запросы stroy-bot-import-works — 1:1 (URL, фильтры, заголовки, тела).
|
||||||
|
|
||||||
|
/** Колонки таблицы работ Dataverse, в которые пишет импорт. */
|
||||||
|
const DV_WORK_COLUMNS = {
|
||||||
|
title: 'cree9_title',
|
||||||
|
volume: 'cree9_volume',
|
||||||
|
unit: 'cree9_unit',
|
||||||
|
objectId: 'cree9_objectid',
|
||||||
|
/** id работы из сметы — по нему узнаём, что работа уже импортирована. */
|
||||||
|
comment: 'cree9_comment'
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Название работы в Dataverse обрезается до 100 символов. */
|
||||||
|
const WORK_TITLE_MAX_LENGTH = 100;
|
||||||
|
/** «Объекты» проекта и импортированные работы читаются одним запросом. */
|
||||||
|
const ITEMS_LIMIT = 5000;
|
||||||
|
|
||||||
|
const DATAVERSE_GET_HEADERS: Record<string, string> = {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'OData-MaxVersion': '4.0',
|
||||||
|
'OData-Version': '4.0'
|
||||||
|
};
|
||||||
|
const DATAVERSE_POST_HEADERS: Record<string, string> = {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'OData-MaxVersion': '4.0',
|
||||||
|
'OData-Version': '4.0'
|
||||||
|
};
|
||||||
|
const SP_NOMETADATA_HEADERS: Record<string, string> = {
|
||||||
|
Accept: 'application/json;odata=nometadata',
|
||||||
|
'Content-Type': 'application/json;odata=nometadata'
|
||||||
|
};
|
||||||
|
|
||||||
|
export function projectItemUrl(siteUrl: string, projectId: number): string {
|
||||||
|
return `${listItemsUrl(siteUrl, LISTS.projects)}(${projectId})?$select=Id,Title,UniqueId0`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Объекты проекта: поле project — текст с названием проекта (апострофы удваиваются для OData). */
|
||||||
|
export function projectObjectsUrl(siteUrl: string, projectTitle: string): string {
|
||||||
|
const escapedTitle = projectTitle.replace(/'/g, "''");
|
||||||
|
return (
|
||||||
|
`${listItemsUrl(siteUrl, LISTS.objects)}` +
|
||||||
|
`?$select=Id,Title,parent/Id&$expand=parent&$filter=project eq '${escapedTitle}'&$top=${ITEMS_LIMIT}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function estimateWorksUrl(apiBaseUrl: string, uniqueId: string, page: number): string {
|
||||||
|
return `${apiBaseUrl}/Project/${uniqueId}/works?page=${page}&pageSize=${API_FETCH_PAGE_SIZE}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function importedWorksUrl(dvUrl: string): string {
|
||||||
|
return dataverseUrl(
|
||||||
|
dvUrl,
|
||||||
|
`${DV_TABLES.works}?$select=${DV_WORK_COLUMNS.comment},${DV_WORK_COLUMNS.objectId}&$top=${ITEMS_LIMIT}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function worksTableUrl(dvUrl: string): string {
|
||||||
|
return dataverseUrl(dvUrl, DV_TABLES.works);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Проект по id. Неуспешный ответ — ошибка со статусом (ensureOk) (страница покажет «Не удалось загрузить данные проекта»). */
|
||||||
|
export async function fetchImportProject(
|
||||||
|
sharePoint: IHttpClient,
|
||||||
|
siteUrl: string,
|
||||||
|
projectId: number
|
||||||
|
): Promise<IProjectItemResponse> {
|
||||||
|
const response = await ensureOk(await sharePoint.fetch(projectItemUrl(siteUrl, projectId)));
|
||||||
|
return (await response.json()) as IProjectItemResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchProjectObjects(
|
||||||
|
sharePoint: IHttpClient,
|
||||||
|
siteUrl: string,
|
||||||
|
projectTitle: string
|
||||||
|
): Promise<IImportObject[]> {
|
||||||
|
const response = await ensureOk(await sharePoint.fetch(projectObjectsUrl(siteUrl, projectTitle)));
|
||||||
|
const data = (await response.json()) as ISpCollection<IObjectItemResponse>;
|
||||||
|
return buildObjectList(data.value || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Все работы сметы, постранично по 200. onProgress — сколько загружено из totalCount. */
|
||||||
|
export async function fetchEstimateWorks(
|
||||||
|
api: IHttpClient,
|
||||||
|
apiBaseUrl: string,
|
||||||
|
uniqueId: string,
|
||||||
|
onProgress: (loaded: number, total: number) => void
|
||||||
|
): Promise<IEstimateWork[]> {
|
||||||
|
const works: IEstimateWork[] = [];
|
||||||
|
let page = 1;
|
||||||
|
let totalPages = 1;
|
||||||
|
do {
|
||||||
|
const response = await ensureOk(
|
||||||
|
await api.fetch(estimateWorksUrl(apiBaseUrl, uniqueId, page), { headers: { Accept: 'application/json' } })
|
||||||
|
);
|
||||||
|
const data = (await response.json()) as IEstimateWorksPage;
|
||||||
|
works.push(...(data.items || []));
|
||||||
|
totalPages = apiTotalPages(data);
|
||||||
|
onProgress(works.length, data.totalCount || 0);
|
||||||
|
page++;
|
||||||
|
} while (page <= totalPages);
|
||||||
|
return works;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Уже импортированные работы (по всем объектам). Ошибка не мешает странице —
|
||||||
|
* как в старом коде, просто ни одна работа не считается импортированной.
|
||||||
|
*/
|
||||||
|
export async function fetchImportedWorks(dataverse: IHttpClient, dvUrl: string): Promise<ImportedWorks> {
|
||||||
|
const imported: ImportedWorks = new Map();
|
||||||
|
try {
|
||||||
|
const response = await dataverse.fetch(importedWorksUrl(dvUrl), { method: 'GET', headers: DATAVERSE_GET_HEADERS });
|
||||||
|
if (!response.ok) return imported;
|
||||||
|
const data = (await response.json()) as { value?: Array<Record<string, unknown>> };
|
||||||
|
(data.value || []).forEach((row) => {
|
||||||
|
const workId = row[DV_WORK_COLUMNS.comment];
|
||||||
|
const objectId = parseInt(String(row[DV_WORK_COLUMNS.objectId]), 10);
|
||||||
|
if (typeof workId === 'string' && workId && !isNaN(objectId)) imported.set(workId, objectId);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка загрузки импортированных работ:', error);
|
||||||
|
}
|
||||||
|
return imported;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Новый элемент «Объекты». Родитель пишется в parentId (а читается как parent/Id) — так
|
||||||
|
* делал import-works; схему списка не меняем без проверки на сайте.
|
||||||
|
*/
|
||||||
|
export async function createObject(
|
||||||
|
sharePoint: IHttpClient,
|
||||||
|
siteUrl: string,
|
||||||
|
item: { title: string; projectTitle: string; parentId: number | undefined }
|
||||||
|
): Promise<number> {
|
||||||
|
const body: { Title: string; project: string; parentId?: number } = { Title: item.title, project: item.projectTitle };
|
||||||
|
if (item.parentId !== undefined) body.parentId = item.parentId;
|
||||||
|
|
||||||
|
const response = await sharePoint.fetch(listItemsUrl(siteUrl, LISTS.objects), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: SP_NOMETADATA_HEADERS,
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(IMPORT_TEXTS.errors.createObject(item.title));
|
||||||
|
const data = (await response.json()) as { Id: number };
|
||||||
|
return data.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Работа сметы → строка cree9_tables. id работы сохраняется в cree9_comment (защита от дублей). */
|
||||||
|
export async function postImportedWork(
|
||||||
|
dataverse: IHttpClient,
|
||||||
|
dvUrl: string,
|
||||||
|
work: IEstimateWork,
|
||||||
|
objectId: number
|
||||||
|
): Promise<void> {
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
[DV_WORK_COLUMNS.title]: (work.name || '').substring(0, WORK_TITLE_MAX_LENGTH),
|
||||||
|
// Пустой объём уходит явным null — как в старом коде.
|
||||||
|
[DV_WORK_COLUMNS.volume]: work.volume ?? null,
|
||||||
|
[DV_WORK_COLUMNS.unit]: work.unit || '',
|
||||||
|
[DV_WORK_COLUMNS.objectId]: String(objectId),
|
||||||
|
[DV_WORK_COLUMNS.comment]: work.id
|
||||||
|
};
|
||||||
|
const response = await dataverse.fetch(worksTableUrl(dvUrl), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: DATAVERSE_POST_HEADERS,
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(await response.text());
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import {
|
||||||
|
buildObjectList,
|
||||||
|
buildPageRange,
|
||||||
|
buildSearchIndex,
|
||||||
|
buildStructureHint,
|
||||||
|
clampPage,
|
||||||
|
countPages,
|
||||||
|
filterObjects,
|
||||||
|
filterWorks,
|
||||||
|
findObjectId,
|
||||||
|
formatVolume,
|
||||||
|
importedObjectTitle,
|
||||||
|
isAllSelected,
|
||||||
|
isHttpResponseError,
|
||||||
|
parseImportProjectId,
|
||||||
|
pickWorksToImport,
|
||||||
|
toggleSelectAll,
|
||||||
|
worksCountLabel
|
||||||
|
} from './importRules';
|
||||||
|
import { IEstimateWork, IImportObject } from './importTypes';
|
||||||
|
|
||||||
|
const work = (id: string, overrides: Partial<IEstimateWork> = {}): IEstimateWork => ({ id, ...overrides });
|
||||||
|
|
||||||
|
describe('parseImportProjectId', () => {
|
||||||
|
it('parseInt, пусто — NaN («Не указан projectId в URL»)', () => {
|
||||||
|
expect(parseImportProjectId('12')).toBe(12);
|
||||||
|
expect(parseImportProjectId(undefined)).toBeNaN();
|
||||||
|
expect(parseImportProjectId('abc')).toBeNaN();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildObjectList', () => {
|
||||||
|
it('полный путь через « / », защита от циклов, сортировка ru', () => {
|
||||||
|
const objects = buildObjectList([
|
||||||
|
{ Id: 1, Title: 'Яблоня' },
|
||||||
|
{ Id: 2, Title: 'Арка', parent: { Id: 1 } },
|
||||||
|
{ Id: 3, Title: 'Петля', parent: { Id: 4 } },
|
||||||
|
{ Id: 4, Title: 'Цикл', parent: { Id: 3 } }
|
||||||
|
]);
|
||||||
|
expect(objects.map((o) => o.fullPath)).toEqual(['Петля / Цикл', 'Цикл / Петля', 'Яблоня', 'Яблоня / Арка']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('filterObjects', () => {
|
||||||
|
const many: IImportObject[] = Array.from({ length: 150 }, (_, i) => ({
|
||||||
|
id: i,
|
||||||
|
title: `Объект ${i}`,
|
||||||
|
fullPath: `Корпус / Объект ${i}`,
|
||||||
|
parentId: undefined
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('🐞 видны только первые 100 совпадений', () => {
|
||||||
|
expect(filterObjects(many, '')).toHaveLength(100);
|
||||||
|
expect(filterObjects(many, 'КОРПУС')).toHaveLength(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('поиск по пути без учёта регистра', () => {
|
||||||
|
expect(filterObjects(many, 'объект 14').map((o) => o.id)).toEqual([14, 140, 141, 142, 143, 144, 145, 146, 147, 148, 149]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatVolume', () => {
|
||||||
|
it('ru-RU, 3 знака; пусто — «—»', () => {
|
||||||
|
expect(formatVolume(1234.5)).toBe((1234.5).toLocaleString('ru-RU', { minimumFractionDigits: 3 }));
|
||||||
|
expect(formatVolume(2)).toBe('2,000');
|
||||||
|
expect(formatVolume(undefined)).toBe('—');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('поиск работ', () => {
|
||||||
|
const works = [
|
||||||
|
work('w1', { name: 'Бетонирование', codeSnb: 'Е06-01', unit: 'м3', volume: 12.5, objectName: 'Корпус А' }),
|
||||||
|
work('w2', { name: 'Кладка', localEstimateName: 'Смета 2', volume: 3 })
|
||||||
|
];
|
||||||
|
const index = buildSearchIndex(works);
|
||||||
|
|
||||||
|
it('ищет по названию, коду, ед. изм., объекту, смете, объёму (как есть и в ru-формате), id', () => {
|
||||||
|
['бетон', 'е06', 'м3', 'корпус', 'смета 2', '12.5', '12,500', 'w2'].forEach((query) => {
|
||||||
|
expect(filterWorks(works, index, query).length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('запрос обрезается и без учёта регистра; пустой — все работы', () => {
|
||||||
|
expect(filterWorks(works, index, ' КЛАДКА ').map((w) => w.id)).toEqual(['w2']);
|
||||||
|
expect(filterWorks(works, index, ' ')).toBe(works);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('пагинация', () => {
|
||||||
|
it('число страниц: минимум 1', () => {
|
||||||
|
expect(countPages(0, 100)).toBe(1);
|
||||||
|
expect(countPages(100, 100)).toBe(1);
|
||||||
|
expect(countPages(101, 100)).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('переход — в пределах 1…N', () => {
|
||||||
|
expect(clampPage(0, 5)).toBe(1);
|
||||||
|
expect(clampPage(9, 5)).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('кнопки: 1, …, текущая ±3, …, последняя', () => {
|
||||||
|
expect(buildPageRange(1, 1)).toEqual([]);
|
||||||
|
expect(buildPageRange(1, 2)).toEqual([1, 2]);
|
||||||
|
expect(buildPageRange(1, 10)).toEqual([1, 2, 3, 4, 'ellipsis', 10]);
|
||||||
|
expect(buildPageRange(10, 20)).toEqual([1, 'ellipsis', 7, 8, 9, 10, 11, 12, 13, 'ellipsis', 20]);
|
||||||
|
expect(buildPageRange(5, 6)).toEqual([1, 2, 3, 4, 5, 6]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('подпись: все работы или найденные', () => {
|
||||||
|
expect(worksCountLabel('', 250, 250, 1, 3)).toBe('Работы (250), страница 1 из 3');
|
||||||
|
expect(worksCountLabel('бетон', 12, 250, 1, 1)).toBe('Найдено: 12 из 250 — страница 1 из 1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('выделение', () => {
|
||||||
|
const filtered = [work('a'), work('b'), work('c')];
|
||||||
|
const imported = new Map([['b', 7]]);
|
||||||
|
|
||||||
|
it('«выделить всё» — все отфильтрованные неимпортированные (со всех страниц)', () => {
|
||||||
|
expect(Array.from(toggleSelectAll(filtered, imported, new Set()))).toEqual(['a', 'c']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('если всё уже выделено — снимает выделение только с них', () => {
|
||||||
|
const selected = new Set(['a', 'c', 'x']);
|
||||||
|
expect(isAllSelected(filtered, imported, selected)).toBe(true);
|
||||||
|
expect(Array.from(toggleSelectAll(filtered, imported, selected))).toEqual(['x']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('нечего выделять — не «выделено всё»', () => {
|
||||||
|
expect(isAllSelected([work('b')], imported, new Set())).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('к импорту — выбранные и ещё не импортированные, в порядке сметы', () => {
|
||||||
|
expect(pickWorksToImport(filtered, new Set(['c', 'b', 'a']), imported).map((w) => w.id)).toEqual(['a', 'c']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('уже импортированные', () => {
|
||||||
|
const objects: IImportObject[] = [{ id: 7, title: 'Каркас', fullPath: 'Корпус / Каркас', parentId: 1 }];
|
||||||
|
|
||||||
|
it('название объекта (не путь), неизвестный объект — пусто', () => {
|
||||||
|
const imported = new Map([
|
||||||
|
['a', 7],
|
||||||
|
['b', 99]
|
||||||
|
]);
|
||||||
|
expect(importedObjectTitle('a', imported, objects)).toBe('Каркас');
|
||||||
|
expect(importedObjectTitle('b', imported, objects)).toBe('');
|
||||||
|
expect(importedObjectTitle('c', imported, objects)).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildStructureHint', () => {
|
||||||
|
it('уникальные «объект → смета» по выбранным, первые 3 и остаток', () => {
|
||||||
|
const works = [
|
||||||
|
work('1', { objectName: 'А', localEstimateName: 'С1' }),
|
||||||
|
work('2', { objectName: 'А', localEstimateName: 'С1' }),
|
||||||
|
work('3', { objectName: 'Б' }),
|
||||||
|
work('4', { localEstimateName: 'С2' }),
|
||||||
|
work('5', { objectName: 'В', localEstimateName: 'С3' }),
|
||||||
|
work('6'),
|
||||||
|
work('7', { objectName: 'Г' })
|
||||||
|
];
|
||||||
|
const hint = buildStructureHint(works, new Set(['1', '2', '3', '4', '5', '6']));
|
||||||
|
expect(hint).toEqual({ visible: ['А → С1', 'Б', 'С2'], rest: 1 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isHttpResponseError', () => {
|
||||||
|
it('ошибка ответа (есть status) отличается от сбоя запроса', () => {
|
||||||
|
expect(isHttpResponseError(Object.assign(new Error('404 Not Found'), { status: 404 }))).toBe(true);
|
||||||
|
expect(isHttpResponseError(new Error('network'))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findObjectId', () => {
|
||||||
|
const objects: IImportObject[] = [
|
||||||
|
{ id: 1, title: 'Корпус А', fullPath: 'Корпус А', parentId: undefined },
|
||||||
|
{ id: 2, title: 'Каркас', fullPath: 'Корпус А / Каркас', parentId: 1 }
|
||||||
|
];
|
||||||
|
|
||||||
|
it('название без учёта регистра и пробелов, тот же родитель', () => {
|
||||||
|
expect(findObjectId(objects, ' корпус а ', undefined)).toBe(1);
|
||||||
|
expect(findObjectId(objects, 'КАРКАС', 1)).toBe(2);
|
||||||
|
expect(findObjectId(objects, 'Каркас', undefined)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
import {
|
||||||
|
IEstimateWork,
|
||||||
|
IEstimateWorksPage,
|
||||||
|
IImportObject,
|
||||||
|
IObjectItemResponse,
|
||||||
|
ImportedWorks,
|
||||||
|
IStructureHint,
|
||||||
|
PageRangeItem
|
||||||
|
} from './importTypes';
|
||||||
|
import { IMPORT_TEXTS } from './texts';
|
||||||
|
|
||||||
|
// Правила stroy-bot-import-works, вынесенные из DOM-кода в чистые функции.
|
||||||
|
|
||||||
|
/** Работы из API читаются страницами по 200. */
|
||||||
|
export const API_FETCH_PAGE_SIZE = 200;
|
||||||
|
/** В таблице — по 100 работ на страницу. */
|
||||||
|
export const WORKS_PAGE_SIZE = 100;
|
||||||
|
/** В окне выбора объекта показываются первые 100 совпадений. */
|
||||||
|
export const OBJECTS_PAGE_SIZE = 100;
|
||||||
|
/** Сколько строк «Объекты из сметы:» показывать до «+ ещё N». */
|
||||||
|
export const STRUCTURE_HINT_VISIBLE = 3;
|
||||||
|
/** Сколько соседних страниц показывать слева и справа от текущей. */
|
||||||
|
const PAGE_RANGE_NEIGHBOURS = 3;
|
||||||
|
/** Разделитель полей в строке поиска — символ, которого нет в данных. */
|
||||||
|
const SEARCH_FIELD_SEPARATOR = '\u0001';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ошибка неуспешного HTTP-ответа (из ensureOk), а не сбой запроса. Проверяем по полю status, а не
|
||||||
|
* через instanceof HttpError: при компиляции в ES5 наследник Error теряет свой прототип.
|
||||||
|
*/
|
||||||
|
export function isHttpResponseError(error: unknown): boolean {
|
||||||
|
return error instanceof Error && typeof (error as { status?: unknown }).status === 'number';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** projectId из адреса — parseInt, как в старом коде: пусто или не число — NaN. */
|
||||||
|
export function parseImportProjectId(value: string | undefined): number {
|
||||||
|
return value ? parseInt(value, 10) : NaN;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Путь объекта снизу вверх до корня через « / ». Защита от циклов. */
|
||||||
|
export function buildObjectPath(objectId: number, objects: Map<number, IObjectItemResponse>): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
const visited = new Set<number>();
|
||||||
|
let currentId: number | undefined = objectId;
|
||||||
|
while (currentId !== undefined) {
|
||||||
|
if (visited.has(currentId)) break;
|
||||||
|
visited.add(currentId);
|
||||||
|
const item = objects.get(currentId);
|
||||||
|
if (!item) break;
|
||||||
|
parts.unshift(item.Title || '');
|
||||||
|
currentId = item.parent?.Id ?? undefined;
|
||||||
|
}
|
||||||
|
return parts.join(' / ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Объекты проекта с полными путями, по алфавиту путей (ru). */
|
||||||
|
export function buildObjectList(rows: IObjectItemResponse[]): IImportObject[] {
|
||||||
|
const byId = new Map<number, IObjectItemResponse>();
|
||||||
|
rows.forEach((row) => byId.set(row.Id, row));
|
||||||
|
const objects: IImportObject[] = [];
|
||||||
|
byId.forEach((row) => {
|
||||||
|
objects.push({
|
||||||
|
id: row.Id,
|
||||||
|
title: row.Title || '',
|
||||||
|
fullPath: buildObjectPath(row.Id, byId),
|
||||||
|
parentId: row.parent?.Id ?? undefined
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return objects.sort((a, b) => a.fullPath.localeCompare(b.fullPath, 'ru'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Объекты в окне выбора: подстрока пути без учёта регистра.
|
||||||
|
* 🐞 Как в старом коде: пагинация в окне не выводится — видны только первые 100 совпадений.
|
||||||
|
*/
|
||||||
|
export function filterObjects(objects: IImportObject[], search: string): IImportObject[] {
|
||||||
|
const filtered = search
|
||||||
|
? objects.filter((object) => object.fullPath.toLowerCase().indexOf(search.toLowerCase()) !== -1)
|
||||||
|
: objects;
|
||||||
|
return filtered.slice(0, OBJECTS_PAGE_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Объём: ru-RU, ровно 3 знака после запятой; нет значения — «—». */
|
||||||
|
export function formatVolume(value: number | undefined): string {
|
||||||
|
if (value === null || value === undefined) return IMPORT_TEXTS.noValue;
|
||||||
|
return value.toLocaleString('ru-RU', { minimumFractionDigits: 3, maximumFractionDigits: 3 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Строка поиска работы: id, объект, смета, код СНБ, название, объём (как есть и форматированный), ед. изм. */
|
||||||
|
export function buildWorkSearchEntry(work: IEstimateWork): string {
|
||||||
|
const hasVolume = work.volume !== null && work.volume !== undefined;
|
||||||
|
return [
|
||||||
|
work.id || '',
|
||||||
|
work.objectName || '',
|
||||||
|
work.localEstimateName || '',
|
||||||
|
work.codeSnb || '',
|
||||||
|
work.name || '',
|
||||||
|
hasVolume ? String(work.volume) : '',
|
||||||
|
hasVolume ? formatVolume(work.volume) : '',
|
||||||
|
work.unit || ''
|
||||||
|
]
|
||||||
|
.join(SEARCH_FIELD_SEPARATOR)
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSearchIndex(works: IEstimateWork[]): Map<string, string> {
|
||||||
|
const index = new Map<string, string>();
|
||||||
|
works.forEach((work) => index.set(work.id, buildWorkSearchEntry(work)));
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterWorks(works: IEstimateWork[], index: Map<string, string>, query: string): IEstimateWork[] {
|
||||||
|
const normalized = query.trim().toLowerCase();
|
||||||
|
if (!normalized) return works;
|
||||||
|
return works.filter((work) => {
|
||||||
|
const entry = index.get(work.id);
|
||||||
|
return entry !== undefined && entry.indexOf(normalized) !== -1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function countPages(itemCount: number, pageSize: number): number {
|
||||||
|
return Math.max(1, Math.ceil(itemCount / pageSize));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Переход на страницу: в пределах 1…totalPages. */
|
||||||
|
export function clampPage(page: number, totalPages: number): number {
|
||||||
|
return Math.min(Math.max(1, page), totalPages);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Сколько страниц у API: totalPages из ответа, иначе по totalCount. */
|
||||||
|
export function apiTotalPages(page: IEstimateWorksPage): number {
|
||||||
|
return page.totalPages || countPages(page.totalCount || 0, API_FETCH_PAGE_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Кнопки пагинации: 1, «...», текущая ±3, «...», последняя. Одна страница — без пагинации. */
|
||||||
|
export function buildPageRange(current: number, totalPages: number): PageRangeItem[] {
|
||||||
|
if (totalPages <= 1) return [];
|
||||||
|
const range: PageRangeItem[] = [];
|
||||||
|
const add = (item: PageRangeItem): void => {
|
||||||
|
if (range[range.length - 1] !== item) range.push(item);
|
||||||
|
};
|
||||||
|
add(1);
|
||||||
|
const start = Math.max(2, current - PAGE_RANGE_NEIGHBOURS);
|
||||||
|
const end = Math.min(totalPages - 1, current + PAGE_RANGE_NEIGHBOURS);
|
||||||
|
if (start > 2) add('ellipsis');
|
||||||
|
for (let page = start; page <= end; page++) add(page);
|
||||||
|
if (end < totalPages - 1) add('ellipsis');
|
||||||
|
add(totalPages);
|
||||||
|
return range;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Подпись над таблицей: все работы или найденные по запросу. */
|
||||||
|
export function worksCountLabel(query: string, found: number, total: number, page: number, pages: number): string {
|
||||||
|
return query.trim().length === 0
|
||||||
|
? IMPORT_TEXTS.works.countAll(total, page, pages)
|
||||||
|
: IMPORT_TEXTS.works.countFound(found, total, page, pages);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Работы, которые можно выделить: отфильтрованные и ещё не импортированные. */
|
||||||
|
export function selectableWorks(filtered: IEstimateWork[], imported: ImportedWorks): IEstimateWork[] {
|
||||||
|
return filtered.filter((work) => !imported.has(work.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** «Выделено всё» — считается по ВСЕМ отфильтрованным работам, а не по текущей странице (как в старом коде). */
|
||||||
|
export function isAllSelected(filtered: IEstimateWork[], imported: ImportedWorks, selected: Set<string>): boolean {
|
||||||
|
const selectable = selectableWorks(filtered, imported);
|
||||||
|
return selectable.length > 0 && selectable.every((work) => selected.has(work.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* «Выделить всё на странице» / «Снять все на странице».
|
||||||
|
* Несмотря на подпись, действует на все отфильтрованные неимпортированные работы со всех страниц —
|
||||||
|
* так было в старом web part, переносим как есть.
|
||||||
|
*/
|
||||||
|
export function toggleSelectAll(
|
||||||
|
filtered: IEstimateWork[],
|
||||||
|
imported: ImportedWorks,
|
||||||
|
selected: Set<string>
|
||||||
|
): Set<string> {
|
||||||
|
const shouldDeselect = isAllSelected(filtered, imported, selected);
|
||||||
|
const next = new Set(selected);
|
||||||
|
selectableWorks(filtered, imported).forEach((work) => {
|
||||||
|
if (shouldDeselect) next.delete(work.id);
|
||||||
|
else next.add(work.id);
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Выбранные работы, которых ещё нет в Dataverse, в порядке сметы. */
|
||||||
|
export function pickWorksToImport(
|
||||||
|
works: IEstimateWork[],
|
||||||
|
selected: Set<string>,
|
||||||
|
imported: ImportedWorks
|
||||||
|
): IEstimateWork[] {
|
||||||
|
return works.filter((work) => selected.has(work.id) && !imported.has(work.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ключ пары «объект сметы + локальная смета» — по нему кэшируются созданные объекты. */
|
||||||
|
export function structureKey(work: IEstimateWork): string {
|
||||||
|
return `${work.objectName}||${work.localEstimateName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Подсказка «Объекты из сметы:» по выбранным работам: уникальные «объект → смета», первые 3 + остаток. */
|
||||||
|
export function buildStructureHint(works: IEstimateWork[], selected: Set<string>): IStructureHint {
|
||||||
|
const labels = new Map<string, string>();
|
||||||
|
works.forEach((work) => {
|
||||||
|
if (!selected.has(work.id) || !(work.objectName || work.localEstimateName)) return;
|
||||||
|
const key = structureKey(work);
|
||||||
|
if (!labels.has(key)) {
|
||||||
|
labels.set(key, [work.objectName, work.localEstimateName].filter(Boolean).join(' → '));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const items: string[] = [];
|
||||||
|
labels.forEach((label) => items.push(label));
|
||||||
|
return { visible: items.slice(0, STRUCTURE_HINT_VISIBLE), rest: items.length - STRUCTURE_HINT_VISIBLE };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Подзаголовок строки: «объект / смета». */
|
||||||
|
export function workSubtitle(work: IEstimateWork): string {
|
||||||
|
return [work.objectName, work.localEstimateName].filter(Boolean).join(' / ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Название объекта, куда работа уже импортирована (только Title, без пути). */
|
||||||
|
export function importedObjectTitle(
|
||||||
|
workId: string,
|
||||||
|
imported: ImportedWorks,
|
||||||
|
objects: IImportObject[]
|
||||||
|
): string {
|
||||||
|
const objectId = imported.get(workId);
|
||||||
|
if (objectId === undefined) return '';
|
||||||
|
return objects.filter((object) => object.id === objectId)[0]?.title || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Поиск объекта для импорта с созданием: Title без учёта регистра и пробелов по краям
|
||||||
|
* и тот же родитель (undefined — корень).
|
||||||
|
*/
|
||||||
|
export function findObjectId(objects: IImportObject[], title: string, parentId: number | undefined): number | undefined {
|
||||||
|
const normalized = title.toLowerCase().trim();
|
||||||
|
const existing = objects.filter(
|
||||||
|
(object) => object.title.toLowerCase().trim() === normalized && object.parentId === parentId
|
||||||
|
)[0];
|
||||||
|
return existing?.id;
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { IHttpClient } from '../../services/http/IHttpClient';
|
||||||
|
import { IEstimateWork, IImportObject, IImportOutcome } from './importTypes';
|
||||||
|
import { createObject, postImportedWork } from './importRepository';
|
||||||
|
import { findObjectId, structureKey } from './importRules';
|
||||||
|
|
||||||
|
// Сценарии импорта import-works: работы пишутся по одной, ошибка одной работы не останавливает остальные.
|
||||||
|
|
||||||
|
export interface IImportDeps {
|
||||||
|
sharePoint: IHttpClient;
|
||||||
|
dataverse: IHttpClient;
|
||||||
|
siteUrl: string;
|
||||||
|
dataverseUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** «Импортировать»: все работы — в выбранный объект. */
|
||||||
|
export async function importIntoObject(
|
||||||
|
deps: IImportDeps,
|
||||||
|
works: IEstimateWork[],
|
||||||
|
objectId: number,
|
||||||
|
objects: IImportObject[]
|
||||||
|
): Promise<IImportOutcome> {
|
||||||
|
const outcome: IImportOutcome = { successCount: 0, errorCount: 0, imported: [], objects };
|
||||||
|
for (const work of works) {
|
||||||
|
try {
|
||||||
|
await postImportedWork(deps.dataverse, deps.dataverseUrl, work, objectId);
|
||||||
|
outcome.successCount++;
|
||||||
|
outcome.imported.push({ workId: work.id, objectId });
|
||||||
|
} catch (error) {
|
||||||
|
outcome.errorCount++;
|
||||||
|
console.error('Ошибка импорта работы', work.id, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return outcome;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Найти объект по названию и родителю или создать его в «Объекты».
|
||||||
|
* Созданный объект добавляется в конец objects (без пересортировки — как в старом коде).
|
||||||
|
*/
|
||||||
|
async function findOrCreateObject(
|
||||||
|
deps: IImportDeps,
|
||||||
|
objects: IImportObject[],
|
||||||
|
title: string,
|
||||||
|
parentId: number | undefined,
|
||||||
|
projectTitle: string
|
||||||
|
): Promise<number> {
|
||||||
|
const existingId = findObjectId(objects, title, parentId);
|
||||||
|
if (existingId !== undefined) return existingId;
|
||||||
|
|
||||||
|
const newId = await createObject(deps.sharePoint, deps.siteUrl, { title, projectTitle, parentId });
|
||||||
|
const parent = parentId !== undefined ? objects.filter((object) => object.id === parentId)[0] : undefined;
|
||||||
|
objects.push({ id: newId, title, fullPath: parent ? `${parent.fullPath} / ${title}` : title, parentId });
|
||||||
|
return newId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* «Импорт с созданием объекта»: для каждой работы — корневой объект по objectName и дочерний
|
||||||
|
* по localEstimateName (найти или создать). Пара кэшируется на время одного импорта.
|
||||||
|
*/
|
||||||
|
export async function importWithObjectCreation(
|
||||||
|
deps: IImportDeps,
|
||||||
|
works: IEstimateWork[],
|
||||||
|
objects: IImportObject[],
|
||||||
|
projectTitle: string
|
||||||
|
): Promise<IImportOutcome> {
|
||||||
|
const workingObjects = [...objects];
|
||||||
|
const outcome: IImportOutcome = { successCount: 0, errorCount: 0, imported: [], objects: workingObjects };
|
||||||
|
const leafByStructure = new Map<string, number>();
|
||||||
|
|
||||||
|
for (const work of works) {
|
||||||
|
try {
|
||||||
|
const key = structureKey(work);
|
||||||
|
let leafId = leafByStructure.get(key);
|
||||||
|
if (leafId === undefined) {
|
||||||
|
const rootId = await findOrCreateObject(deps, workingObjects, work.objectName || '', undefined, projectTitle);
|
||||||
|
leafId = await findOrCreateObject(deps, workingObjects, work.localEstimateName || '', rootId, projectTitle);
|
||||||
|
leafByStructure.set(key, leafId);
|
||||||
|
}
|
||||||
|
await postImportedWork(deps.dataverse, deps.dataverseUrl, work, leafId);
|
||||||
|
outcome.successCount++;
|
||||||
|
outcome.imported.push({ workId: work.id, objectId: leafId });
|
||||||
|
} catch (error) {
|
||||||
|
outcome.errorCount++;
|
||||||
|
console.error('Ошибка импорта работы', work.id, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return outcome;
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// Типы раздела «Импорт работ» (stroy-bot-import-works).
|
||||||
|
|
||||||
|
/** Проект из списка «Проекты»: UniqueId0 — id проекта в справочнике смет (API). */
|
||||||
|
export interface IImportProject {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
uniqueId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ответ «Проекты»/items(id). UniqueId0 пуст — проект не привязан к справочнику. */
|
||||||
|
export interface IProjectItemResponse {
|
||||||
|
Id: number;
|
||||||
|
Title?: string;
|
||||||
|
UniqueId0?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Элемент «Объекты» как его читает import-works: родитель — lookup parent/Id. */
|
||||||
|
export interface IObjectItemResponse {
|
||||||
|
Id: number;
|
||||||
|
Title?: string;
|
||||||
|
parent?: { Id?: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Объект с полным путём «Корень / Раздел» — для выбора и поиска. parentId undefined — корень. */
|
||||||
|
export interface IImportObject {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
fullPath: string;
|
||||||
|
parentId: number | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Работа из сметы (корпоративный API). */
|
||||||
|
export interface IEstimateWork {
|
||||||
|
id: string;
|
||||||
|
objectName?: string;
|
||||||
|
localEstimateName?: string;
|
||||||
|
codeSnb?: string;
|
||||||
|
name?: string;
|
||||||
|
volume?: number;
|
||||||
|
unit?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Страница ответа `{api}/Project/{UniqueId0}/works`. */
|
||||||
|
export interface IEstimateWorksPage {
|
||||||
|
items?: IEstimateWork[];
|
||||||
|
totalCount?: number;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
totalPages?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Уже импортированные работы: id работы из сметы (cree9_comment) → id объекта (cree9_objectid). */
|
||||||
|
export type ImportedWorks = Map<string, number>;
|
||||||
|
|
||||||
|
/** Итог импорта: счётчики для тоста, что импортировано и куда, и список объектов с созданными. */
|
||||||
|
export interface IImportOutcome {
|
||||||
|
successCount: number;
|
||||||
|
errorCount: number;
|
||||||
|
imported: Array<{ workId: string; objectId: number }>;
|
||||||
|
objects: IImportObject[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Строка подсказки «Объекты из сметы:» — первые 3 и сколько ещё. */
|
||||||
|
export interface IStructureHint {
|
||||||
|
visible: string[];
|
||||||
|
rest: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Элемент пагинации: номер страницы или «...». */
|
||||||
|
export type PageRangeItem = number | 'ellipsis';
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// Тексты раздела «Импорт работ» — дословно из stroy-bot-import-works.
|
||||||
|
|
||||||
|
export const IMPORT_TEXTS = {
|
||||||
|
header: {
|
||||||
|
label: 'ИМПОРТ РАБОТ',
|
||||||
|
loading: 'Загрузка...',
|
||||||
|
projectFallback: 'Проект'
|
||||||
|
},
|
||||||
|
loading: {
|
||||||
|
project: 'Загрузка проекта...',
|
||||||
|
objects: 'Загрузка объектов...',
|
||||||
|
works: 'Загрузка работ...',
|
||||||
|
worksProgress: (loaded: number, total: number): string => `Загрузка работ... ${loaded} из ${total}`
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
noProjectId: 'Не указан projectId в URL',
|
||||||
|
projectNotOk: 'Не удалось загрузить данные проекта',
|
||||||
|
projectFailed: 'Ошибка загрузки данных проекта',
|
||||||
|
objectsNotOk: 'Не удалось загрузить список объектов',
|
||||||
|
objectsFailed: 'Ошибка загрузки объектов',
|
||||||
|
worksNotOk: 'Не удалось загрузить список работ',
|
||||||
|
worksFailed: 'Ошибка загрузки работ',
|
||||||
|
createObject: (title: string): string => `Не удалось создать объект: ${title}`
|
||||||
|
},
|
||||||
|
notBound: 'Проект не привязан к справочнику проектов. Обратитесь к администратору.',
|
||||||
|
objectPicker: {
|
||||||
|
label: 'Объект',
|
||||||
|
placeholder: 'Выберите объект...',
|
||||||
|
clear: 'Сбросить объект',
|
||||||
|
dialogTitle: 'Выбор объекта',
|
||||||
|
search: 'Поиск...',
|
||||||
|
nothingFound: 'Ничего не найдено'
|
||||||
|
},
|
||||||
|
works: {
|
||||||
|
empty: 'Работы не найдены',
|
||||||
|
nothingFound: 'Ничего не найдено по запросу',
|
||||||
|
searchPlaceholder: 'Поиск по работам (название, код СНБ, объект, ед. изм., объём)...',
|
||||||
|
countAll: (total: number, page: number, pages: number): string =>
|
||||||
|
`Работы (${total}), страница ${page} из ${pages}`,
|
||||||
|
countFound: (found: number, total: number, page: number, pages: number): string =>
|
||||||
|
`Найдено: ${found} из ${total} — страница ${page} из ${pages}`,
|
||||||
|
selectAll: 'Выделить всё на странице',
|
||||||
|
deselectAll: 'Снять все на странице',
|
||||||
|
columns: { number: '#', name: 'Наименование', code: 'Код СНБ', unit: 'Ед. изм.', volume: 'Объём' },
|
||||||
|
imported: '✓ импортировано',
|
||||||
|
alreadyImported: 'Уже импортировано',
|
||||||
|
prevPage: '<',
|
||||||
|
nextPage: '>',
|
||||||
|
ellipsis: '...'
|
||||||
|
},
|
||||||
|
importBar: {
|
||||||
|
selected: (count: number): string => `Выбрано: ${count}`,
|
||||||
|
importWithObjects: 'Импорт с созданием объекта',
|
||||||
|
import: 'Импортировать',
|
||||||
|
importing: 'Импорт...'
|
||||||
|
},
|
||||||
|
structureHint: {
|
||||||
|
label: 'Объекты из сметы:',
|
||||||
|
arrow: '→',
|
||||||
|
more: (rest: number): string => `+ ещё ${rest}`
|
||||||
|
},
|
||||||
|
toasts: {
|
||||||
|
selectObject: 'Выберите объект чтобы импортировать работу в объект',
|
||||||
|
success: (count: number): string => `✓ Успешно импортировано: ${count}`,
|
||||||
|
partial: (success: number, errors: number): string => `Импортировано: ${success}, ошибок: ${errors}`
|
||||||
|
},
|
||||||
|
noValue: '—'
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { useAppServices } from '../../app/AppServicesContext';
|
||||||
|
import { IEstimateWork, IImportProject, ImportedWorks } from './importTypes';
|
||||||
|
import { fetchEstimateWorks, fetchImportedWorks } from './importRepository';
|
||||||
|
import { isHttpResponseError } from './importRules';
|
||||||
|
import { IMPORT_TEXTS } from './texts';
|
||||||
|
|
||||||
|
export type EstimateWorksStatus =
|
||||||
|
| { kind: 'loading'; text: string }
|
||||||
|
| { kind: 'error'; message: string }
|
||||||
|
| { kind: 'ready' };
|
||||||
|
|
||||||
|
interface IEstimateWorksState {
|
||||||
|
status: EstimateWorksStatus;
|
||||||
|
works: IEstimateWork[];
|
||||||
|
imported: ImportedWorks;
|
||||||
|
/** Перечитать импортированные работы из Dataverse (при выборе объекта — как в старом коде). */
|
||||||
|
reloadImported: () => void;
|
||||||
|
/** Отметить работы импортированными после успешного импорта. */
|
||||||
|
markImported: (entries: Array<{ workId: string; objectId: number }>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Сначала — уже импортированные работы из Dataverse, затем — все работы сметы из API (с прогрессом). */
|
||||||
|
export function useEstimateWorks(project: IImportProject): IEstimateWorksState {
|
||||||
|
const { tnsuApi, dataverse, settings } = useAppServices();
|
||||||
|
const [status, setStatus] = React.useState<EstimateWorksStatus>({ kind: 'loading', text: IMPORT_TEXTS.loading.works });
|
||||||
|
const [works, setWorks] = React.useState<IEstimateWork[]>([]);
|
||||||
|
const [imported, setImported] = React.useState<ImportedWorks>(new Map());
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
let isCancelled = false;
|
||||||
|
const load = async (): Promise<void> => {
|
||||||
|
const importedWorks = await fetchImportedWorks(dataverse, settings.dataverseUrl);
|
||||||
|
if (isCancelled) return;
|
||||||
|
setImported(importedWorks);
|
||||||
|
const onProgress = (loaded: number, total: number): void => {
|
||||||
|
if (!isCancelled) setStatus({ kind: 'loading', text: IMPORT_TEXTS.loading.worksProgress(loaded, total) });
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const loaded = await fetchEstimateWorks(tnsuApi, settings.apiBaseUrl, project.uniqueId, onProgress);
|
||||||
|
if (isCancelled) return;
|
||||||
|
setWorks(loaded);
|
||||||
|
setStatus({ kind: 'ready' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка загрузки работ:', error);
|
||||||
|
const message = isHttpResponseError(error) ? IMPORT_TEXTS.errors.worksNotOk : IMPORT_TEXTS.errors.worksFailed;
|
||||||
|
if (!isCancelled) setStatus({ kind: 'error', message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
load().catch((unexpected) => console.error(unexpected));
|
||||||
|
return () => {
|
||||||
|
isCancelled = true;
|
||||||
|
};
|
||||||
|
}, [tnsuApi, dataverse, settings.dataverseUrl, settings.apiBaseUrl, project.uniqueId]);
|
||||||
|
|
||||||
|
const reloadImported = React.useCallback((): void => {
|
||||||
|
fetchImportedWorks(dataverse, settings.dataverseUrl)
|
||||||
|
.then((importedWorks) => setImported(importedWorks))
|
||||||
|
.catch((error) => console.error(error));
|
||||||
|
}, [dataverse, settings.dataverseUrl]);
|
||||||
|
|
||||||
|
const markImported = React.useCallback((entries: Array<{ workId: string; objectId: number }>): void => {
|
||||||
|
setImported((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
entries.forEach((entry) => next.set(entry.workId, entry.objectId));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { status, works, imported, reloadImported, markImported };
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { useAppServices } from '../../app/AppServicesContext';
|
||||||
|
import { IHttpClient } from '../../services/http/IHttpClient';
|
||||||
|
import { IImportObject, IImportProject, IProjectItemResponse } from './importTypes';
|
||||||
|
import { fetchImportProject, fetchProjectObjects } from './importRepository';
|
||||||
|
import { isHttpResponseError, parseImportProjectId } from './importRules';
|
||||||
|
import { IMPORT_TEXTS } from './texts';
|
||||||
|
|
||||||
|
/** Что показать под шапкой: загрузка / ошибка / проект не привязан / рабочая область. */
|
||||||
|
export type ImportContentState =
|
||||||
|
| { kind: 'loading'; text: string }
|
||||||
|
| { kind: 'error'; message: string }
|
||||||
|
| { kind: 'notBound' }
|
||||||
|
| { kind: 'ready'; project: IImportProject; objects: IImportObject[] };
|
||||||
|
|
||||||
|
interface IImportProjectState {
|
||||||
|
/** Заголовок в шапке: «Загрузка...» до ответа «Проекты», затем название проекта. */
|
||||||
|
headerTitle: string;
|
||||||
|
content: ImportContentState;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ILoadCallbacks {
|
||||||
|
onHeader: (title: string) => void;
|
||||||
|
onContent: (content: ImportContentState) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadObjects(
|
||||||
|
sharePoint: IHttpClient,
|
||||||
|
siteUrl: string,
|
||||||
|
project: IImportProject,
|
||||||
|
callbacks: ILoadCallbacks
|
||||||
|
): Promise<void> {
|
||||||
|
callbacks.onContent({ kind: 'loading', text: IMPORT_TEXTS.loading.objects });
|
||||||
|
try {
|
||||||
|
const objects = await fetchProjectObjects(sharePoint, siteUrl, project.title);
|
||||||
|
callbacks.onContent({ kind: 'ready', project, objects });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка загрузки объектов:', error);
|
||||||
|
const message = isHttpResponseError(error) ? IMPORT_TEXTS.errors.objectsNotOk : IMPORT_TEXTS.errors.objectsFailed;
|
||||||
|
callbacks.onContent({ kind: 'error', message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Как в import-works: «Проекты»/items(id) → проверка UniqueId0 → «Объекты» проекта.
|
||||||
|
* Неуспешный ответ и сбой запроса дают разные тексты ошибок — как раньше.
|
||||||
|
*/
|
||||||
|
async function loadImportSource(
|
||||||
|
sharePoint: IHttpClient,
|
||||||
|
siteUrl: string,
|
||||||
|
projectId: number,
|
||||||
|
callbacks: ILoadCallbacks
|
||||||
|
): Promise<void> {
|
||||||
|
callbacks.onContent({ kind: 'loading', text: IMPORT_TEXTS.loading.project });
|
||||||
|
let data: IProjectItemResponse;
|
||||||
|
try {
|
||||||
|
data = await fetchImportProject(sharePoint, siteUrl, projectId);
|
||||||
|
} catch (error) {
|
||||||
|
if (!isHttpResponseError(error)) console.error('Ошибка загрузки проекта:', error);
|
||||||
|
const message = isHttpResponseError(error) ? IMPORT_TEXTS.errors.projectNotOk : IMPORT_TEXTS.errors.projectFailed;
|
||||||
|
callbacks.onContent({ kind: 'error', message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
callbacks.onHeader(data.Title || IMPORT_TEXTS.header.projectFallback);
|
||||||
|
if (!data.UniqueId0) {
|
||||||
|
callbacks.onContent({ kind: 'notBound' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await loadObjects(sharePoint, siteUrl, { id: data.Id, title: data.Title || '', uniqueId: data.UniqueId0 }, callbacks);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Проект и его объекты для страницы импорта. */
|
||||||
|
export function useImportProject(projectIdParam: string | undefined): IImportProjectState {
|
||||||
|
const { sharePoint, settings } = useAppServices();
|
||||||
|
const [headerTitle, setHeaderTitle] = React.useState<string>(IMPORT_TEXTS.header.loading);
|
||||||
|
const [content, setContent] = React.useState<ImportContentState>({
|
||||||
|
kind: 'loading',
|
||||||
|
text: IMPORT_TEXTS.loading.project
|
||||||
|
});
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
let isCancelled = false;
|
||||||
|
const callbacks: ILoadCallbacks = {
|
||||||
|
onHeader: (title) => {
|
||||||
|
if (!isCancelled) setHeaderTitle(title);
|
||||||
|
},
|
||||||
|
onContent: (next) => {
|
||||||
|
if (!isCancelled) setContent(next);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const projectId = parseImportProjectId(projectIdParam);
|
||||||
|
if (isNaN(projectId)) {
|
||||||
|
callbacks.onContent({ kind: 'error', message: IMPORT_TEXTS.errors.noProjectId });
|
||||||
|
} else {
|
||||||
|
loadImportSource(sharePoint, settings.siteUrl, projectId, callbacks).catch((error) => console.error(error));
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
isCancelled = true;
|
||||||
|
};
|
||||||
|
}, [sharePoint, settings.siteUrl, projectIdParam]);
|
||||||
|
|
||||||
|
return { headerTitle, content };
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
export type ToastType = 'success' | 'error';
|
||||||
|
|
||||||
|
export interface IToast {
|
||||||
|
id: number;
|
||||||
|
message: string;
|
||||||
|
type: ToastType;
|
||||||
|
/** Тост гаснет (анимация исчезновения) перед удалением. */
|
||||||
|
isLeaving: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Тост виден 3 секунды — как в старом web part. */
|
||||||
|
const TOAST_VISIBLE_MS = 3000;
|
||||||
|
/** Длительность анимации исчезновения (совпадает с transition в стилях). */
|
||||||
|
const TOAST_FADE_MS = 250;
|
||||||
|
|
||||||
|
interface IToastsState {
|
||||||
|
toasts: IToast[];
|
||||||
|
showToast: (message: string, type: ToastType) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Простые тосты раздела импорта (в ui-kit тостов нет). Таймеры снимаются при уходе со страницы. */
|
||||||
|
export function useToasts(): IToastsState {
|
||||||
|
const [toasts, setToasts] = React.useState<IToast[]>([]);
|
||||||
|
const nextIdRef = React.useRef<number>(1);
|
||||||
|
const timersRef = React.useRef<number[]>([]);
|
||||||
|
|
||||||
|
React.useEffect(
|
||||||
|
() => () => {
|
||||||
|
timersRef.current.forEach((timer) => window.clearTimeout(timer));
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const showToast = React.useCallback((message: string, type: ToastType): void => {
|
||||||
|
const id = nextIdRef.current++;
|
||||||
|
setToasts((prev) => [...prev, { id, message, type, isLeaving: false }]);
|
||||||
|
|
||||||
|
const leaveTimer = window.setTimeout(() => {
|
||||||
|
setToasts((prev) => prev.map((toast) => (toast.id === id ? { ...toast, isLeaving: true } : toast)));
|
||||||
|
}, TOAST_VISIBLE_MS);
|
||||||
|
const removeTimer = window.setTimeout(() => {
|
||||||
|
setToasts((prev) => prev.filter((toast) => toast.id !== id));
|
||||||
|
}, TOAST_VISIBLE_MS + TOAST_FADE_MS);
|
||||||
|
timersRef.current.push(leaveTimer, removeTimer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { toasts, showToast };
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { useAppServices } from '../../app/AppServicesContext';
|
||||||
|
import { IEstimateWork, IImportObject, IImportOutcome, IImportProject, ImportedWorks } from './importTypes';
|
||||||
|
import { pickWorksToImport } from './importRules';
|
||||||
|
import { IImportDeps, importIntoObject, importWithObjectCreation } from './importService';
|
||||||
|
import { ToastType } from './useToasts';
|
||||||
|
import { IMPORT_TEXTS } from './texts';
|
||||||
|
|
||||||
|
export interface IWorkImportParams {
|
||||||
|
project: IImportProject;
|
||||||
|
objects: IImportObject[];
|
||||||
|
onObjectsChange: (objects: IImportObject[]) => void;
|
||||||
|
selectedObjectId: number | undefined;
|
||||||
|
works: IEstimateWork[];
|
||||||
|
imported: ImportedWorks;
|
||||||
|
selected: Set<string>;
|
||||||
|
/** После импорта: отметить импортированные и снять выделение. */
|
||||||
|
onImported: (entries: IImportOutcome['imported']) => void;
|
||||||
|
showToast: (message: string, type: ToastType) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IWorkImport {
|
||||||
|
isImporting: boolean;
|
||||||
|
importIntoSelectedObject: () => void;
|
||||||
|
importWithObjects: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Кнопки «Импортировать» и «Импорт с созданием объекта». Повторный запуск во время импорта игнорируется. */
|
||||||
|
export function useWorkImport(params: IWorkImportParams): IWorkImport {
|
||||||
|
const { sharePoint, dataverse, settings } = useAppServices();
|
||||||
|
const [isImporting, setIsImporting] = React.useState<boolean>(false);
|
||||||
|
// Ref, а не только state: второй клик до перерисовки тоже должен быть отсечён.
|
||||||
|
const isImportingRef = React.useRef<boolean>(false);
|
||||||
|
|
||||||
|
const deps: IImportDeps = {
|
||||||
|
sharePoint,
|
||||||
|
dataverse,
|
||||||
|
siteUrl: settings.siteUrl,
|
||||||
|
dataverseUrl: settings.dataverseUrl
|
||||||
|
};
|
||||||
|
|
||||||
|
const finish = (outcome: IImportOutcome): void => {
|
||||||
|
isImportingRef.current = false;
|
||||||
|
setIsImporting(false);
|
||||||
|
params.onObjectsChange(outcome.objects);
|
||||||
|
params.onImported(outcome.imported);
|
||||||
|
if (outcome.errorCount === 0) {
|
||||||
|
params.showToast(IMPORT_TEXTS.toasts.success(outcome.successCount), 'success');
|
||||||
|
} else {
|
||||||
|
params.showToast(IMPORT_TEXTS.toasts.partial(outcome.successCount, outcome.errorCount), 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const run = (start: (works: IEstimateWork[]) => Promise<IImportOutcome>): void => {
|
||||||
|
const works = pickWorksToImport(params.works, params.selected, params.imported);
|
||||||
|
if (works.length === 0) return;
|
||||||
|
isImportingRef.current = true;
|
||||||
|
setIsImporting(true);
|
||||||
|
start(works)
|
||||||
|
.then(finish)
|
||||||
|
.catch((unexpected) => console.error(unexpected));
|
||||||
|
};
|
||||||
|
|
||||||
|
const importIntoSelectedObject = (): void => {
|
||||||
|
if (isImportingRef.current) return;
|
||||||
|
const objectId = params.selectedObjectId;
|
||||||
|
if (objectId === undefined) {
|
||||||
|
params.showToast(IMPORT_TEXTS.toasts.selectObject, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
run((works) => importIntoObject(deps, works, objectId, params.objects));
|
||||||
|
};
|
||||||
|
|
||||||
|
const importWithObjects = (): void => {
|
||||||
|
if (isImportingRef.current) return;
|
||||||
|
run((works) => importWithObjectCreation(deps, works, params.objects, params.project.title));
|
||||||
|
};
|
||||||
|
|
||||||
|
return { isImporting, importIntoSelectedObject, importWithObjects };
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { IEstimateWork, ImportedWorks } from './importTypes';
|
||||||
|
import {
|
||||||
|
buildSearchIndex,
|
||||||
|
clampPage,
|
||||||
|
countPages,
|
||||||
|
filterWorks,
|
||||||
|
isAllSelected,
|
||||||
|
toggleSelectAll,
|
||||||
|
WORKS_PAGE_SIZE
|
||||||
|
} from './importRules';
|
||||||
|
|
||||||
|
/** Поиск применяется через 250 мс после последнего ввода — как в старом коде. */
|
||||||
|
const SEARCH_DEBOUNCE_MS = 250;
|
||||||
|
|
||||||
|
export interface IWorksBrowser {
|
||||||
|
/** Текст в поле поиска (применяется с задержкой). */
|
||||||
|
searchText: string;
|
||||||
|
onSearchTextChange: (value: string) => void;
|
||||||
|
/** Применённый запрос. */
|
||||||
|
query: string;
|
||||||
|
filtered: IEstimateWork[];
|
||||||
|
page: number;
|
||||||
|
totalPages: number;
|
||||||
|
pageWorks: IEstimateWork[];
|
||||||
|
/** Номер первой строки страницы минус один — для колонки «#». */
|
||||||
|
pageOffset: number;
|
||||||
|
goToPage: (page: number) => void;
|
||||||
|
selected: Set<string>;
|
||||||
|
toggleWork: (workId: string) => void;
|
||||||
|
isAllSelected: boolean;
|
||||||
|
toggleAll: () => void;
|
||||||
|
clearSelection: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Поиск, клиентская пагинация по 100 и выбор работ. Запросов к API нет. */
|
||||||
|
export function useWorksBrowser(works: IEstimateWork[], imported: ImportedWorks): IWorksBrowser {
|
||||||
|
const [searchText, setSearchText] = React.useState<string>('');
|
||||||
|
const [query, setQuery] = React.useState<string>('');
|
||||||
|
const [requestedPage, setRequestedPage] = React.useState<number>(1);
|
||||||
|
const [selected, setSelected] = React.useState<Set<string>>(new Set());
|
||||||
|
const debounceRef = React.useRef<number | undefined>(undefined);
|
||||||
|
|
||||||
|
React.useEffect(() => () => window.clearTimeout(debounceRef.current), []);
|
||||||
|
|
||||||
|
const searchIndex = React.useMemo(() => buildSearchIndex(works), [works]);
|
||||||
|
const filtered = React.useMemo(() => filterWorks(works, searchIndex, query), [works, searchIndex, query]);
|
||||||
|
const totalPages = countPages(filtered.length, WORKS_PAGE_SIZE);
|
||||||
|
// Страница за пределами списка сбрасывается на первую — как при перерисовке в старом коде.
|
||||||
|
const page = requestedPage > totalPages ? 1 : requestedPage;
|
||||||
|
const pageOffset = (page - 1) * WORKS_PAGE_SIZE;
|
||||||
|
|
||||||
|
const onSearchTextChange = (value: string): void => {
|
||||||
|
setSearchText(value);
|
||||||
|
window.clearTimeout(debounceRef.current);
|
||||||
|
debounceRef.current = window.setTimeout(() => {
|
||||||
|
setQuery(value);
|
||||||
|
setRequestedPage(1);
|
||||||
|
}, SEARCH_DEBOUNCE_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleWork = (workId: string): void => {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(workId)) next.delete(workId);
|
||||||
|
else next.add(workId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
searchText,
|
||||||
|
onSearchTextChange,
|
||||||
|
query,
|
||||||
|
filtered,
|
||||||
|
page,
|
||||||
|
totalPages,
|
||||||
|
pageWorks: filtered.slice(pageOffset, pageOffset + WORKS_PAGE_SIZE),
|
||||||
|
pageOffset,
|
||||||
|
goToPage: (target: number) => setRequestedPage(clampPage(target, totalPages)),
|
||||||
|
selected,
|
||||||
|
toggleWork,
|
||||||
|
isAllSelected: isAllSelected(filtered, imported, selected),
|
||||||
|
toggleAll: () => setSelected((prev) => toggleSelectAll(filtered, imported, prev)),
|
||||||
|
clearSelection: () => setSelected(new Set())
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user