diff --git a/docs/MIGRATION-CHECKLIST.md b/docs/MIGRATION-CHECKLIST.md index 53cd08b..1eb271e 100644 --- a/docs/MIGRATION-CHECKLIST.md +++ b/docs/MIGRATION-CHECKLIST.md @@ -197,6 +197,13 @@ 🐞 Пагинация в модалке объектов не выводится (видны только первые 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=` diff --git a/src/webparts/stroyBot/pages/import/ImportBar.tsx b/src/webparts/stroyBot/pages/import/ImportBar.tsx new file mode 100644 index 0000000..dedab94 --- /dev/null +++ b/src/webparts/stroyBot/pages/import/ImportBar.tsx @@ -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 = ({ selectedCount, actions }) => ( +
+ {TEXTS.selected(selectedCount)} + + +
+); diff --git a/src/webparts/stroyBot/pages/import/ImportHeader.tsx b/src/webparts/stroyBot/pages/import/ImportHeader.tsx new file mode 100644 index 0000000..9969dc7 --- /dev/null +++ b/src/webparts/stroyBot/pages/import/ImportHeader.tsx @@ -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 = ({ title }) => ( +
+ +
+ {IMPORT_TEXTS.header.label} + {title} +
+
+); diff --git a/src/webparts/stroyBot/pages/import/ImportToasts.tsx b/src/webparts/stroyBot/pages/import/ImportToasts.tsx new file mode 100644 index 0000000..aa2a85f --- /dev/null +++ b/src/webparts/stroyBot/pages/import/ImportToasts.tsx @@ -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 = ({ toasts }) => { + if (toasts.length === 0) return null; + return ( +
+ {toasts.map((toast) => { + const typeClass = toast.type === 'success' ? styles.toastSuccess : styles.toastError; + const leavingClass = toast.isLeaving ? styles.toastLeaving : ''; + return ( +
+ {toast.message} +
+ ); + })} +
+ ); +}; diff --git a/src/webparts/stroyBot/pages/import/ImportWorksPage.module.scss b/src/webparts/stroyBot/pages/import/ImportWorksPage.module.scss new file mode 100644 index 0000000..69629b9 --- /dev/null +++ b/src/webparts/stroyBot/pages/import/ImportWorksPage.module.scss @@ -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; + } +} diff --git a/src/webparts/stroyBot/pages/import/ImportWorksPage.tsx b/src/webparts/stroyBot/pages/import/ImportWorksPage.tsx new file mode 100644 index 0000000..365c3f8 --- /dev/null +++ b/src/webparts/stroyBot/pages/import/ImportWorksPage.tsx @@ -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 ( +
+ + {content.kind === 'loading' && ( +
+ +
+ )} + {content.kind === 'error' && ( +
+ {content.message} +
+ )} + {content.kind === 'notBound' && ( +
+ {IMPORT_TEXTS.notBound} +
+ )} + {content.kind === 'ready' && ( + + )} +
+ ); +}; diff --git a/src/webparts/stroyBot/pages/import/ImportWorkspace.tsx b/src/webparts/stroyBot/pages/import/ImportWorkspace.tsx new file mode 100644 index 0000000..cfe8f6d --- /dev/null +++ b/src/webparts/stroyBot/pages/import/ImportWorkspace.tsx @@ -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 = ({ project, initialObjects }) => { + // Объекты дополняются созданными при «Импорт с созданием объекта» — поэтому свой state. + const [objects, setObjects] = React.useState(initialObjects); + const [selectedObjectId, setSelectedObjectId] = React.useState(undefined); + const [isPickerOpen, setIsPickerOpen] = React.useState(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 ( + <> + setIsPickerOpen(true)} + onClear={() => setSelectedObjectId(undefined)} + /> + {isPickerOpen && ( + setIsPickerOpen(false)} /> + )} + + {estimate.status.kind === 'loading' && ( +
+ +
+ )} + {estimate.status.kind === 'error' && ( +
+ {estimate.status.message} +
+ )} + {estimate.status.kind === 'ready' && ( + + )} + + + + ); +}; diff --git a/src/webparts/stroyBot/pages/import/ObjectPickerDialog.tsx b/src/webparts/stroyBot/pages/import/ObjectPickerDialog.tsx new file mode 100644 index 0000000..80b9ad6 --- /dev/null +++ b/src/webparts/stroyBot/pages/import/ObjectPickerDialog.tsx @@ -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 = ({ objects, onSelect, onDismiss }) => { + const [search, setSearch] = React.useState(''); + const visibleObjects = filterObjects(objects, search); + + return ( + + +
+ {visibleObjects.length === 0 &&
{TEXTS.nothingFound}
} + {visibleObjects.map((object) => ( + + ))} +
+
+ ); +}; diff --git a/src/webparts/stroyBot/pages/import/ObjectPickerField.tsx b/src/webparts/stroyBot/pages/import/ObjectPickerField.tsx new file mode 100644 index 0000000..9182a80 --- /dev/null +++ b/src/webparts/stroyBot/pages/import/ObjectPickerField.tsx @@ -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 = ({ selectedObject, onOpen, onClear }) => { + const onKeyDown = (event: React.KeyboardEvent): void => { + if (event.target !== event.currentTarget) return; + if (event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); + onOpen(); + }; + + const onClearClick = (event: React.MouseEvent): void => { + event.stopPropagation(); + onClear(); + }; + + return ( +
+
{TEXTS.label}
+
+ + {selectedObject ? selectedObject.fullPath : TEXTS.placeholder} + + {selectedObject && ( + + )} + +
+
+ ); +}; diff --git a/src/webparts/stroyBot/pages/import/StructureHint.tsx b/src/webparts/stroyBot/pages/import/StructureHint.tsx new file mode 100644 index 0000000..97477eb --- /dev/null +++ b/src/webparts/stroyBot/pages/import/StructureHint.tsx @@ -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 = ({ hint }) => { + if (hint.visible.length === 0) return null; + return ( +
+
{TEXTS.label}
+ {hint.visible.map((label) => ( +
+ {TEXTS.arrow} + + {label} + +
+ ))} + {hint.rest > 0 &&
{TEXTS.more(hint.rest)}
} +
+ ); +}; diff --git a/src/webparts/stroyBot/pages/import/WorkRow.tsx b/src/webparts/stroyBot/pages/import/WorkRow.tsx new file mode 100644 index 0000000..56a5cee --- /dev/null +++ b/src/webparts/stroyBot/pages/import/WorkRow.tsx @@ -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 = ({ 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 ( +
+ {/* Клик по флажку обрабатывает сам флажок — не даём ему всплыть до строки и переключить второй раз. */} +
event.stopPropagation()} + > + onToggle(work.id)} + ariaLabel={work.name || work.id} + /> +
+
{rowNumber}
+
+ + {work.name} + {importedObjectTitle && ( + + {importedObjectTitle} + + )} + {isImported && ( + + {TEXTS.imported} + + )} + + {subtitle && {subtitle}} +
+
{work.codeSnb || ''}
+
{work.unit || ''}
+
{formatVolume(work.volume)}
+
+ ); +}; diff --git a/src/webparts/stroyBot/pages/import/WorksPagination.tsx b/src/webparts/stroyBot/pages/import/WorksPagination.tsx new file mode 100644 index 0000000..9bdea11 --- /dev/null +++ b/src/webparts/stroyBot/pages/import/WorksPagination.tsx @@ -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 = ({ page, totalPages, onPageChange }) => { + const range = buildPageRange(page, totalPages); + if (range.length === 0) return null; + + return ( +
+ + {range.map((item, index) => + item === 'ellipsis' ? ( + // Многоточий может быть два — ключ по позиции. + + {TEXTS.ellipsis} + + ) : ( + + ) + )} + +
+ ); +}; diff --git a/src/webparts/stroyBot/pages/import/WorksSection.tsx b/src/webparts/stroyBot/pages/import/WorksSection.tsx new file mode 100644 index 0000000..4016757 --- /dev/null +++ b/src/webparts/stroyBot/pages/import/WorksSection.tsx @@ -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 = ({ works, imported, objects, browser, importer }) => { + if (works.length === 0) { + return ( +
+ +
+ ); + } + + const countLabel = worksCountLabel( + browser.query, + browser.filtered.length, + works.length, + browser.page, + browser.totalPages + ); + + return ( +
+
+
{countLabel}
+ +
+
+ +
+ + + {browser.selected.size > 0 && } + +
+ ); +}; diff --git a/src/webparts/stroyBot/pages/import/WorksTable.tsx b/src/webparts/stroyBot/pages/import/WorksTable.tsx new file mode 100644 index 0000000..524351a --- /dev/null +++ b/src/webparts/stroyBot/pages/import/WorksTable.tsx @@ -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; + onToggle: (workId: string) => void; +} + +/** Таблица работ: #, Наименование, Код СНБ, Ед. изм., Объём. */ +export const WorksTable: React.FC = ({ works, pageOffset, imported, objects, selected, onToggle }) => ( + <> +
+
+
{COLUMNS.number}
+
{COLUMNS.name}
+
{COLUMNS.code}
+
{COLUMNS.unit}
+
{COLUMNS.volume}
+
+
+ {works.length === 0 &&
{IMPORT_TEXTS.works.nothingFound}
} + {works.map((work, index) => ( + + ))} +
+ +); diff --git a/src/webparts/stroyBot/pages/import/importRepository.test.ts b/src/webparts/stroyBot/pages/import/importRepository.test.ts new file mode 100644 index 0000000..564eca3 --- /dev/null +++ b/src/webparts/stroyBot/pages/import/importRepository.test.ts @@ -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 }]); + }); +}); diff --git a/src/webparts/stroyBot/pages/import/importRepository.ts b/src/webparts/stroyBot/pages/import/importRepository.ts new file mode 100644 index 0000000..7ee64a9 --- /dev/null +++ b/src/webparts/stroyBot/pages/import/importRepository.ts @@ -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 = { + Accept: 'application/json', + 'OData-MaxVersion': '4.0', + 'OData-Version': '4.0' +}; +const DATAVERSE_POST_HEADERS: Record = { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'OData-MaxVersion': '4.0', + 'OData-Version': '4.0' +}; +const SP_NOMETADATA_HEADERS: Record = { + 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 { + 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 { + const response = await ensureOk(await sharePoint.fetch(projectObjectsUrl(siteUrl, projectTitle))); + const data = (await response.json()) as ISpCollection; + return buildObjectList(data.value || []); +} + +/** Все работы сметы, постранично по 200. onProgress — сколько загружено из totalCount. */ +export async function fetchEstimateWorks( + api: IHttpClient, + apiBaseUrl: string, + uniqueId: string, + onProgress: (loaded: number, total: number) => void +): Promise { + 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 { + 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> }; + (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 { + 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 { + const body: Record = { + [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()); +} diff --git a/src/webparts/stroyBot/pages/import/importRules.test.ts b/src/webparts/stroyBot/pages/import/importRules.test.ts new file mode 100644 index 0000000..3486d29 --- /dev/null +++ b/src/webparts/stroyBot/pages/import/importRules.test.ts @@ -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 => ({ 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(); + }); +}); diff --git a/src/webparts/stroyBot/pages/import/importRules.ts b/src/webparts/stroyBot/pages/import/importRules.ts new file mode 100644 index 0000000..62c983a --- /dev/null +++ b/src/webparts/stroyBot/pages/import/importRules.ts @@ -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): string { + const parts: string[] = []; + const visited = new Set(); + 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(); + 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 { + const index = new Map(); + works.forEach((work) => index.set(work.id, buildWorkSearchEntry(work))); + return index; +} + +export function filterWorks(works: IEstimateWork[], index: Map, 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): 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 +): Set { + 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, + 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): IStructureHint { + const labels = new Map(); + 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; +} diff --git a/src/webparts/stroyBot/pages/import/importService.ts b/src/webparts/stroyBot/pages/import/importService.ts new file mode 100644 index 0000000..3492f6a --- /dev/null +++ b/src/webparts/stroyBot/pages/import/importService.ts @@ -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 { + 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 { + 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 { + const workingObjects = [...objects]; + const outcome: IImportOutcome = { successCount: 0, errorCount: 0, imported: [], objects: workingObjects }; + const leafByStructure = new Map(); + + 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; +} diff --git a/src/webparts/stroyBot/pages/import/importTypes.ts b/src/webparts/stroyBot/pages/import/importTypes.ts new file mode 100644 index 0000000..70f6196 --- /dev/null +++ b/src/webparts/stroyBot/pages/import/importTypes.ts @@ -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; + +/** Итог импорта: счётчики для тоста, что импортировано и куда, и список объектов с созданными. */ +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'; diff --git a/src/webparts/stroyBot/pages/import/texts.ts b/src/webparts/stroyBot/pages/import/texts.ts new file mode 100644 index 0000000..ce9ed4c --- /dev/null +++ b/src/webparts/stroyBot/pages/import/texts.ts @@ -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; diff --git a/src/webparts/stroyBot/pages/import/useEstimateWorks.ts b/src/webparts/stroyBot/pages/import/useEstimateWorks.ts new file mode 100644 index 0000000..c74079e --- /dev/null +++ b/src/webparts/stroyBot/pages/import/useEstimateWorks.ts @@ -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({ kind: 'loading', text: IMPORT_TEXTS.loading.works }); + const [works, setWorks] = React.useState([]); + const [imported, setImported] = React.useState(new Map()); + + React.useEffect(() => { + let isCancelled = false; + const load = async (): Promise => { + 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 }; +} diff --git a/src/webparts/stroyBot/pages/import/useImportProject.ts b/src/webparts/stroyBot/pages/import/useImportProject.ts new file mode 100644 index 0000000..4df7550 --- /dev/null +++ b/src/webparts/stroyBot/pages/import/useImportProject.ts @@ -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 { + 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 { + 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(IMPORT_TEXTS.header.loading); + const [content, setContent] = React.useState({ + 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 }; +} diff --git a/src/webparts/stroyBot/pages/import/useToasts.ts b/src/webparts/stroyBot/pages/import/useToasts.ts new file mode 100644 index 0000000..08c258e --- /dev/null +++ b/src/webparts/stroyBot/pages/import/useToasts.ts @@ -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([]); + const nextIdRef = React.useRef(1); + const timersRef = React.useRef([]); + + 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 }; +} diff --git a/src/webparts/stroyBot/pages/import/useWorkImport.ts b/src/webparts/stroyBot/pages/import/useWorkImport.ts new file mode 100644 index 0000000..672ae64 --- /dev/null +++ b/src/webparts/stroyBot/pages/import/useWorkImport.ts @@ -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; + /** После импорта: отметить импортированные и снять выделение. */ + 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(false); + // Ref, а не только state: второй клик до перерисовки тоже должен быть отсечён. + const isImportingRef = React.useRef(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): 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 }; +} diff --git a/src/webparts/stroyBot/pages/import/useWorksBrowser.ts b/src/webparts/stroyBot/pages/import/useWorksBrowser.ts new file mode 100644 index 0000000..f3b687e --- /dev/null +++ b/src/webparts/stroyBot/pages/import/useWorksBrowser.ts @@ -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; + 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(''); + const [query, setQuery] = React.useState(''); + const [requestedPage, setRequestedPage] = React.useState(1); + const [selected, setSelected] = React.useState>(new Set()); + const debounceRef = React.useRef(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()) + }; +}