diff --git a/docs/MIGRATION-CHECKLIST.md b/docs/MIGRATION-CHECKLIST.md
index 571f724..75bafe2 100644
--- a/docs/MIGRATION-CHECKLIST.md
+++ b/docs/MIGRATION-CHECKLIST.md
@@ -221,6 +221,14 @@
- [ ] Сообщения успеха и ошибок
- [ ] Запись `UniqueId0` и `UniqueName` в «Проекты»
+🐞 Ответы поиска не сверяются с последним запросом — при быстром вводе может победить устаревший. 🐞 У результата без полей `latitude`/`longitude` выводится «Координаты: undefined, undefined» (проверка только на `null`).
+
+**Сознательные отличия от binder:**
+- Сайт списка «Проекты» — из общей настройки «URL сайта SharePoint» (было отдельное свойство `listSiteUrl`, значение по умолчанию то же), адрес API — из «Базовый URL API».
+- У поля поиска своя кнопка очистки ✕ («Очистить») вместо встроенной в Fluent `SearchBox`; очистка по Esc не переносилась.
+- Сообщения успеха / ошибки сохранения закрываются крестиком рядом с `Alert` (у `Alert` ui-kit нет закрытия).
+- Статус, «Привязан» / «Не привязан» — `Badge` ui-kit тех же цветов; кнопка «Связать» — `primary` ui-kit вместо принудительно синей Fluent.
+
---
## Открытые вопросы
diff --git a/src/webparts/stroyBot/pages/binder/BinderPage.module.scss b/src/webparts/stroyBot/pages/binder/BinderPage.module.scss
new file mode 100644
index 0000000..a9811c3
--- /dev/null
+++ b/src/webparts/stroyBot/pages/binder/BinderPage.module.scss
@@ -0,0 +1,247 @@
+@import '../../../../styles/design-tokens';
+
+// Толщина акцентных полос карточек — как в binder.
+$accent-bar-width: 3px;
+$accent-line-height: 2px;
+// На самых узких экранах карточки компактнее — точка перелома из binder (между $bp-xs и $bp-sm).
+$compact-card-bp: 480px;
+
+.page {
+ display: flex;
+ flex-direction: column;
+ gap: $sp-4;
+ width: 100%;
+}
+
+.dismissibleAlert {
+ display: flex;
+ align-items: flex-start;
+ gap: $sp-2;
+}
+
+.dismissibleAlertBody {
+ flex: 1;
+ min-width: 0;
+}
+
+.hairline {
+ @include hairline;
+ margin: 0;
+}
+
+// ---- Карточка проекта SharePoint ----
+
+.projectCard {
+ position: relative;
+ overflow: hidden;
+ width: 100%;
+ box-sizing: border-box;
+ padding: $sp-6 $sp-7;
+ background: $white;
+ border: 1px solid $gray-200;
+ border-radius: $r-lg;
+ box-shadow: $sh-2;
+
+ // Navy-линия сверху.
+ &::after {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: $accent-line-height;
+ background: linear-gradient(90deg, $navy-800, $navy-600 60%, transparent);
+ }
+
+ // Золотая полоса слева.
+ &::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ width: $accent-bar-width;
+ background: $grad-gold-v;
+ }
+}
+
+.projectTitle {
+ margin: 0;
+ font-family: $font-serif;
+ font-size: $fs-form-title;
+ font-weight: 600;
+ color: $navy-900;
+ overflow-wrap: break-word;
+}
+
+.projectFields {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: $sp-1 $sp-4;
+ margin-top: $sp-3;
+}
+
+.fieldLine {
+ min-width: 0;
+ font-size: $fs-label;
+ color: $gray-500;
+ overflow-wrap: break-word;
+ word-break: break-word;
+}
+
+.fieldLabel {
+ font-weight: 600;
+ color: $gray-700;
+}
+
+.bindingRow {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: $sp-2;
+ margin-top: $sp-3;
+ padding-top: $sp-3;
+ border-top: 1px solid $gray-200;
+}
+
+.boundInfo {
+ flex: 1;
+ min-width: 0;
+ font-size: $fs-label;
+ color: $gray-500;
+ overflow-wrap: break-word;
+ word-break: break-word;
+}
+
+.unbindAction {
+ margin-top: $sp-2;
+}
+
+// ---- Поиск по справочнику ----
+
+.sectionTitle {
+ margin: 0;
+ font-family: $font-serif;
+ font-size: $fs-section-title;
+ font-weight: 600;
+ color: $navy-900;
+}
+
+.searchBox {
+ display: flex;
+ align-items: center;
+ gap: $sp-1;
+}
+
+.searchInput {
+ flex: 1;
+ min-width: 0;
+}
+
+.emptyResults {
+ display: block;
+ padding: $sp-4 0;
+ font-size: $fs-label;
+ text-align: center;
+ color: $gray-400;
+}
+
+.results {
+ display: flex;
+ flex-direction: column;
+ gap: $sp-2;
+}
+
+.resultItem {
+ position: relative;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ gap: $sp-1;
+ width: 100%;
+ box-sizing: border-box;
+ padding: $sp-3 $sp-4;
+ background: $white;
+ border: 1px solid $gray-200;
+ border-radius: $r-md;
+ box-shadow: $sh-1;
+ cursor: pointer;
+ transition: 0.22s;
+
+ &::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ width: $accent-bar-width;
+ background: $grad-gold-v;
+ opacity: 0;
+ transition: 0.22s;
+ }
+
+ &:hover {
+ box-shadow: $sh-3;
+ transform: translateY(-2px);
+ border-color: transparent;
+
+ &::before {
+ opacity: 1;
+ }
+ }
+}
+
+.resultItemSelected {
+ background: $gold-50;
+ border-color: $gold-400;
+
+ &::before {
+ opacity: 1;
+ }
+
+ &:hover {
+ background: $gold-50;
+ border-color: $gold-400;
+ }
+}
+
+.resultName {
+ font-size: $fs-body;
+ font-weight: 600;
+ color: $navy-900;
+ overflow-wrap: break-word;
+ word-break: break-word;
+}
+
+.bindAction {
+ margin-top: $sp-1;
+}
+
+.pagination {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: center;
+ gap: $sp-2;
+}
+
+.pageInfo {
+ font-size: $fs-label;
+ color: $gray-500;
+}
+
+@include below($bp-sm) {
+ .projectFields {
+ grid-template-columns: 1fr;
+ }
+}
+
+@include below($compact-card-bp) {
+ .projectCard {
+ padding: $sp-3 $sp-4;
+ }
+
+ .resultItem {
+ padding: $sp-2 $sp-3;
+ }
+}
diff --git a/src/webparts/stroyBot/pages/binder/BinderPage.tsx b/src/webparts/stroyBot/pages/binder/BinderPage.tsx
new file mode 100644
index 0000000..ed4a8fc
--- /dev/null
+++ b/src/webparts/stroyBot/pages/binder/BinderPage.tsx
@@ -0,0 +1,65 @@
+import * as React from 'react';
+import { Alert, Spinner } from '@tnsu/ui-kit';
+import { useRouter } from '../../app/routing/RouterContext';
+import { isProjectBound, parseProjectId } from './binderRules';
+import { ICatalogProject } from './binderTypes';
+import { useBinderProject } from './useBinderProject';
+import { useProjectBinding } from './useProjectBinding';
+import { useCatalogSearch } from './useCatalogSearch';
+import { ProjectCard } from './ProjectCard';
+import { CatalogSection } from './CatalogSection';
+import { DismissibleAlert } from './DismissibleAlert';
+import { BINDER_TEXTS } from './texts';
+import styles from './BinderPage.module.scss';
+
+/** Привязка к справочнику (бывший stroy-bot-projects-binder): связь проекта SharePoint с проектом API. */
+export const BinderPage: React.FC = () => {
+ const { route } = useRouter();
+ const projectId = parseProjectId(route.projectId);
+ const { project, setProject, isLoading, projectError } = useBinderProject(projectId);
+ const binding = useProjectBinding(project, setProject);
+ const catalog = useCatalogSearch(!isLoading);
+
+ if (isLoading) {
+ return ;
+ }
+
+ const onBind = (selected: ICatalogProject): void => {
+ binding
+ .bind(selected)
+ .then((isSaved) => {
+ if (isSaved) catalog.clearSelection();
+ })
+ .catch(() => undefined);
+ };
+
+ return (
+
+ {projectError &&
{projectError}}
+ {project && (
+
{
+ binding.unbind().catch(() => undefined);
+ }}
+ />
+ )}
+
+ {binding.successMessage && (
+
+ {binding.successMessage}
+
+ )}
+ {binding.saveError && (
+
+ {binding.saveError}
+
+ )}
+
+
+
+
+
+ );
+};
diff --git a/src/webparts/stroyBot/pages/binder/CatalogPagination.tsx b/src/webparts/stroyBot/pages/binder/CatalogPagination.tsx
new file mode 100644
index 0000000..a7e4b62
--- /dev/null
+++ b/src/webparts/stroyBot/pages/binder/CatalogPagination.tsx
@@ -0,0 +1,42 @@
+import * as React from 'react';
+import { Button } from '@tnsu/ui-kit';
+import { canGoToNextPage, canGoToPreviousPage } from './binderRules';
+import { BINDER_TEXTS } from './texts';
+import styles from './BinderPage.module.scss';
+
+interface Props {
+ currentPage: number;
+ totalPages: number;
+ totalCount: number;
+ isSearching: boolean;
+ onPageChange: (page: number) => void;
+}
+
+/** «← {страница} / {всего страниц} · всего: N →». Во время поиска кнопки неактивны. */
+export const CatalogPagination: React.FC = ({
+ currentPage,
+ totalPages,
+ totalCount,
+ isSearching,
+ onPageChange
+}) => (
+
+
+ {BINDER_TEXTS.pageInfo(currentPage, totalPages, totalCount)}
+
+
+);
diff --git a/src/webparts/stroyBot/pages/binder/CatalogResultCard.tsx b/src/webparts/stroyBot/pages/binder/CatalogResultCard.tsx
new file mode 100644
index 0000000..57259e7
--- /dev/null
+++ b/src/webparts/stroyBot/pages/binder/CatalogResultCard.tsx
@@ -0,0 +1,50 @@
+import * as React from 'react';
+import { Button } from '@tnsu/ui-kit';
+import { hasCatalogCoordinates } from './binderRules';
+import { ICatalogProject } from './binderTypes';
+import { FieldLine } from './FieldLine';
+import { BINDER_TEXTS } from './texts';
+import styles from './BinderPage.module.scss';
+
+const FIELDS = BINDER_TEXTS.fields;
+
+interface Props {
+ project: ICatalogProject;
+ isSelected: boolean;
+ isSaving: boolean;
+ onToggle: (project: ICatalogProject) => void;
+ onBind: (project: ICatalogProject) => void;
+}
+
+/** Карточка проекта из справочника. Клик — выбрать / снять выбор; у выбранной — «Связать». */
+export const CatalogResultCard: React.FC = ({ project, isSelected, isSaving, onToggle, onBind }) => {
+ const onBindClick = (event: React.MouseEvent): void => {
+ // Иначе клик дойдёт до карточки и снимет выбор.
+ event.stopPropagation();
+ onBind(project);
+ };
+
+ return (
+ onToggle(project)}
+ >
+
{project.name}
+ {project.code &&
{project.code}}
+ {project.description &&
{project.description}}
+ {project.address &&
{project.address}}
+ {hasCatalogCoordinates(project) && (
+
+ {BINDER_TEXTS.coordinates(project.latitude ?? undefined, project.longitude ?? undefined)}
+
+ )}
+ {isSelected && (
+
+
+
+ )}
+
+ );
+};
diff --git a/src/webparts/stroyBot/pages/binder/CatalogSection.tsx b/src/webparts/stroyBot/pages/binder/CatalogSection.tsx
new file mode 100644
index 0000000..e2f397f
--- /dev/null
+++ b/src/webparts/stroyBot/pages/binder/CatalogSection.tsx
@@ -0,0 +1,72 @@
+import * as React from 'react';
+import { Alert, IconButton, Spinner, TextInput } from '@tnsu/ui-kit';
+import { ICatalogSearch } from './useCatalogSearch';
+import { ICatalogProject } from './binderTypes';
+import { CatalogResultCard } from './CatalogResultCard';
+import { CatalogPagination } from './CatalogPagination';
+import { BINDER_TEXTS } from './texts';
+import styles from './BinderPage.module.scss';
+
+interface Props {
+ catalog: ICatalogSearch;
+ isBound: boolean;
+ isSaving: boolean;
+ onBind: (project: ICatalogProject) => void;
+}
+
+/** Поиск по справочнику API: поле, результаты с выбором, пагинация. */
+export const CatalogSection: React.FC = ({ catalog, isBound, isSaving, onBind }) => {
+ const isNothingFound = !catalog.isSearching && catalog.results.length === 0 && catalog.search.trim() !== '';
+
+ return (
+ <>
+
+ {isBound ? BINDER_TEXTS.searchTitleBound : BINDER_TEXTS.searchTitleUnbound}
+
+
+
+
+
+ {catalog.search && (
+
catalog.changeSearch('')}
+ />
+ )}
+
+
+ {catalog.isSearching && }
+ {catalog.searchError && {catalog.searchError}}
+ {isNothingFound && {BINDER_TEXTS.notFound}}
+
+
+ {catalog.results.map((project) => (
+
+ ))}
+
+
+ {catalog.totalCount > 0 && (
+
+ )}
+ >
+ );
+};
diff --git a/src/webparts/stroyBot/pages/binder/DismissibleAlert.tsx b/src/webparts/stroyBot/pages/binder/DismissibleAlert.tsx
new file mode 100644
index 0000000..55deda8
--- /dev/null
+++ b/src/webparts/stroyBot/pages/binder/DismissibleAlert.tsx
@@ -0,0 +1,21 @@
+import * as React from 'react';
+import { Alert, IconButton } from '@tnsu/ui-kit';
+import styles from './BinderPage.module.scss';
+
+interface Props {
+ variant: 'warn' | 'ok' | 'err' | 'info';
+ dismissLabel: string;
+ onDismiss: () => void;
+ children: React.ReactNode;
+}
+
+// Alert из ui-kit не закрывается, а MessageBar в binder закрывался крестиком — добавляем его рядом.
+// Такой же компонент есть в pages/resources — кандидат на перенос в shared/components.
+export const DismissibleAlert: React.FC = ({ variant, dismissLabel, onDismiss, children }) => (
+
+);
diff --git a/src/webparts/stroyBot/pages/binder/FieldLine.tsx b/src/webparts/stroyBot/pages/binder/FieldLine.tsx
new file mode 100644
index 0000000..5cf2d68
--- /dev/null
+++ b/src/webparts/stroyBot/pages/binder/FieldLine.tsx
@@ -0,0 +1,16 @@
+import * as React from 'react';
+import styles from './BinderPage.module.scss';
+
+interface Props {
+ /** Подпись с двоеточием и пробелом, например «Адрес: ». */
+ label: string;
+ children: React.ReactNode;
+}
+
+/** Строка «Подпись: значение» в карточке проекта и в результатах поиска. */
+export const FieldLine: React.FC = ({ label, children }) => (
+
+ {label}
+ {children}
+
+);
diff --git a/src/webparts/stroyBot/pages/binder/ProjectCard.tsx b/src/webparts/stroyBot/pages/binder/ProjectCard.tsx
new file mode 100644
index 0000000..3b9b535
--- /dev/null
+++ b/src/webparts/stroyBot/pages/binder/ProjectCard.tsx
@@ -0,0 +1,74 @@
+import * as React from 'react';
+import { Badge, BadgeVariant, Button } from '@tnsu/ui-kit';
+import {
+ decodeHtmlEntities,
+ formatDateRu,
+ getBinderStatusKind,
+ hasProjectCoordinates,
+ isProjectBound
+} from './binderRules';
+import { BinderStatusKind, IBinderProject } from './binderTypes';
+import { FieldLine } from './FieldLine';
+import { BINDER_TEXTS } from './texts';
+import styles from './BinderPage.module.scss';
+
+const FIELDS = BINDER_TEXTS.fields;
+
+// Цвета статусов как в binder: текущий — зелёный, архивный — золотой, остальные — серый.
+const STATUS_VARIANT: Record = {
+ current: 'success',
+ archive: 'warning',
+ other: 'muted'
+};
+
+interface Props {
+ project: IBinderProject;
+ isSaving: boolean;
+ onUnbind: () => void;
+}
+
+/** Карточка проекта SharePoint: поля, состояние привязки и «Отвязать». */
+export const ProjectCard: React.FC = ({ project, isSaving, onUnbind }) => {
+ const isBound = isProjectBound(project);
+
+ return (
+
+
{project.title}
+
+ {project.description && {project.description}}
+ {project.address && {decodeHtmlEntities(project.address)}}
+ {project.status && (
+
+
+ {project.status}
+
+
+ )}
+ {project.startDate && {formatDateRu(project.startDate)}}
+ {project.endDate && {formatDateRu(project.endDate)}}
+ {hasProjectCoordinates(project) && (
+
+ {BINDER_TEXTS.coordinates(project.latitude, project.longitude)}
+
+ )}
+
+
+
+ {isBound ? BINDER_TEXTS.bound : BINDER_TEXTS.unbound}
+ {isBound && (
+
+ {BINDER_TEXTS.boundInfo(project.uniqueName ?? '', project.uniqueId ?? '')}
+
+ )}
+
+
+ {isBound && (
+
+
+
+ )}
+
+ );
+};
diff --git a/src/webparts/stroyBot/pages/binder/binderRepository.test.ts b/src/webparts/stroyBot/pages/binder/binderRepository.test.ts
new file mode 100644
index 0000000..b82d4c6
--- /dev/null
+++ b/src/webparts/stroyBot/pages/binder/binderRepository.test.ts
@@ -0,0 +1,144 @@
+import {
+ bindProject,
+ binderProjectUrl,
+ catalogSearchUrl,
+ fetchBinderProject,
+ searchCatalog,
+ unbindProject
+} from './binderRepository';
+import { ICatalogProject } from './binderTypes';
+import { createFakeHttpClient } from '../../shared/testing/fakeHttpClient';
+
+const SITE = 'https://tnsukz.sharepoint.com/sites/stroy';
+const API = 'https://api.tnsu.kz';
+const PROJECT_ITEM = `${SITE}/_api/web/lists/getbytitle('Проекты')/items(5)`;
+
+const MERGE_HEADERS = {
+ Accept: 'application/json;odata=nometadata',
+ 'Content-type': 'application/json;odata=nometadata',
+ 'odata-version': '',
+ 'IF-MATCH': '*',
+ 'X-HTTP-Method': 'MERGE'
+};
+
+const catalogProject: ICatalogProject = {
+ id: 1,
+ oid: 'a1b2-c3',
+ name: 'ЖК Сункар',
+ description: '',
+ address: '',
+ latitude: null,
+ longitude: null
+};
+
+describe('проект SharePoint (1:1 с binder)', () => {
+ it('адрес с теми же полями $select', () => {
+ expect(binderProjectUrl(SITE, 5)).toEqual(
+ `${PROJECT_ITEM}?$select=Id,Title,UniqueId0,UniqueName,description,address,` +
+ `startDate,endDate,Status,latitude,longitude`
+ );
+ });
+
+ it('GET с nometadata и пустым odata-version, null → undefined', async () => {
+ const client = createFakeHttpClient([
+ {
+ match: 'items(5)',
+ response: {
+ body: {
+ Id: 5,
+ Title: 'Сункар',
+ UniqueId0: null,
+ UniqueName: null,
+ Status: 'текущий',
+ latitude: 0,
+ longitude: 71.4
+ }
+ }
+ }
+ ]);
+
+ const project = await fetchBinderProject(client, SITE, 5);
+
+ expect(client.requests[0]).toEqual({
+ url: binderProjectUrl(SITE, 5),
+ request: { headers: { Accept: 'application/json;odata=nometadata', 'odata-version': '' } }
+ });
+ expect(project).toEqual({
+ id: 5,
+ title: 'Сункар',
+ uniqueId: undefined,
+ uniqueName: undefined,
+ description: undefined,
+ address: undefined,
+ startDate: undefined,
+ endDate: undefined,
+ status: 'текущий',
+ latitude: 0,
+ longitude: 71.4
+ });
+ });
+
+ it('ошибка — «Ошибка загрузки проекта: {status}»', async () => {
+ const client = createFakeHttpClient([{ match: 'items(5)', response: { status: 404 } }]);
+ await expect(fetchBinderProject(client, SITE, 5)).rejects.toThrow('Ошибка загрузки проекта: 404');
+ });
+});
+
+describe('справочник API', () => {
+ it('без имени — только страница и размер', () => {
+ expect(catalogSearchUrl(API, '', 1)).toEqual(`${API}/Project?page=1&pageSize=20`);
+ expect(catalogSearchUrl(API, ' ', 2)).toEqual(`${API}/Project?page=2&pageSize=20`);
+ });
+
+ it('с именем — name кодируется целиком, без обрезки пробелов', () => {
+ expect(catalogSearchUrl(API, 'ЖК Сункар ', 3)).toEqual(
+ `${API}/Project?page=3&pageSize=20&name=${encodeURIComponent('ЖК Сункар ')}`
+ );
+ });
+
+ it('GET через клиент API, ответ — страница результатов', async () => {
+ const page = { items: [catalogProject], totalCount: 41, page: 2, pageSize: 20, totalPages: 3 };
+ const client = createFakeHttpClient([{ match: '/Project?', response: { body: page } }]);
+
+ expect(await searchCatalog(client, API, 'сункар', 2)).toEqual(page);
+ expect(client.requests[0]).toEqual({
+ url: `${API}/Project?page=2&pageSize=20&name=%D1%81%D1%83%D0%BD%D0%BA%D0%B0%D1%80`,
+ request: {}
+ });
+ });
+
+ it('ошибка — «Ошибка поиска: {status}»', async () => {
+ const client = createFakeHttpClient([{ match: '/Project?', response: { status: 401 } }]);
+ await expect(searchCatalog(client, API, '', 1)).rejects.toThrow('Ошибка поиска: 401');
+ });
+});
+
+describe('привязка / отвязка (MERGE)', () => {
+ it('«Связать» пишет UniqueId0 = oid и UniqueName = name', async () => {
+ const client = createFakeHttpClient([{ match: 'items(5)', response: { status: 204 } }]);
+
+ await bindProject(client, SITE, 5, catalogProject);
+
+ expect(client.requests[0]).toEqual({
+ url: PROJECT_ITEM,
+ request: { method: 'POST', headers: MERGE_HEADERS, body: '{"UniqueId0":"a1b2-c3","UniqueName":"ЖК Сункар"}' }
+ });
+ });
+
+ it('«Отвязать» пишет null в оба поля', async () => {
+ const client = createFakeHttpClient([{ match: 'items(5)', response: { status: 204 } }]);
+
+ await unbindProject(client, SITE, 5);
+
+ expect(client.requests[0]).toEqual({
+ url: PROJECT_ITEM,
+ request: { method: 'POST', headers: MERGE_HEADERS, body: '{"UniqueId0":null,"UniqueName":null}' }
+ });
+ });
+
+ it('тексты ошибок как в binder', async () => {
+ const client = createFakeHttpClient([{ match: 'items(5)', response: { status: 412 } }]);
+ await expect(bindProject(client, SITE, 5, catalogProject)).rejects.toThrow('Ошибка сохранения: 412');
+ await expect(unbindProject(client, SITE, 5)).rejects.toThrow('Ошибка: 412');
+ });
+});
diff --git a/src/webparts/stroyBot/pages/binder/binderRepository.ts b/src/webparts/stroyBot/pages/binder/binderRepository.ts
new file mode 100644
index 0000000..94b0c96
--- /dev/null
+++ b/src/webparts/stroyBot/pages/binder/binderRepository.ts
@@ -0,0 +1,137 @@
+import { IHttpClient } from '../../services/http/IHttpClient';
+import { listItemsUrl } from '../../shared/sharepoint/spRest';
+import { LISTS } from '../../config/lists';
+import { IBinderProject, ICatalogPage, ICatalogProject, ISpBinderProjectRow } from './binderTypes';
+import { BINDER_TEXTS } from './texts';
+
+// Запросы раздела — 1:1 со stroy-bot-projects-binder (_loadSpProject, _doSearch, _handleBind, _handleUnbind).
+
+/** Поля «Проекты», которые читает раздел (UniqueId0/UniqueName — привязка к справочнику). */
+const PROJECT_FIELDS = [
+ 'Id',
+ 'Title',
+ 'UniqueId0',
+ 'UniqueName',
+ 'description',
+ 'address',
+ 'startDate',
+ 'endDate',
+ 'Status',
+ 'latitude',
+ 'longitude'
+];
+
+/** Размер страницы справочника — как в binder. */
+export const CATALOG_PAGE_SIZE = 20;
+
+/** Заголовки чтения как в binder: nometadata и пустой odata-version (отключает OData 4.0 от SPHttpClient). */
+export const SP_READ_HEADERS: Record = {
+ Accept: 'application/json;odata=nometadata',
+ 'odata-version': ''
+};
+
+/** Заголовки записи как в binder (написание ключей сохранено). */
+export const SP_MERGE_HEADERS: Record = {
+ Accept: 'application/json;odata=nometadata',
+ 'Content-type': 'application/json;odata=nometadata',
+ 'odata-version': '',
+ 'IF-MATCH': '*',
+ 'X-HTTP-Method': 'MERGE'
+};
+
+/** SharePoint отвечает на MERGE 204 No Content — это тоже успех. */
+const NO_CONTENT = 204;
+
+export function projectItemUrl(siteUrl: string, itemId: number): string {
+ return `${listItemsUrl(siteUrl, LISTS.projects)}(${itemId})`;
+}
+
+export function binderProjectUrl(siteUrl: string, projectId: number): string {
+ return `${projectItemUrl(siteUrl, projectId)}?$select=${PROJECT_FIELDS.join(',')}`;
+}
+
+/** Пустой запрос (или из пробелов) — весь справочник, иначе фильтр по имени. */
+export function catalogSearchUrl(apiBaseUrl: string, query: string, page: number): string {
+ const nameParam = query.trim() ? `&name=${encodeURIComponent(query)}` : '';
+ return `${apiBaseUrl}/Project?page=${page}&pageSize=${CATALOG_PAGE_SIZE}${nameParam}`;
+}
+
+function toBinderProject(row: ISpBinderProjectRow): IBinderProject {
+ return {
+ id: row.Id,
+ title: row.Title,
+ uniqueId: row.UniqueId0 ?? undefined,
+ uniqueName: row.UniqueName ?? undefined,
+ description: row.description ?? undefined,
+ address: row.address ?? undefined,
+ startDate: row.startDate ?? undefined,
+ endDate: row.endDate ?? undefined,
+ status: row.Status ?? undefined,
+ latitude: row.latitude ?? undefined,
+ longitude: row.longitude ?? undefined
+ };
+}
+
+/** Тексты ошибок — как в binder: «Ошибка загрузки проекта: 404» и т.п. (без тела ответа). */
+export async function fetchBinderProject(
+ client: IHttpClient,
+ siteUrl: string,
+ projectId: number
+): Promise {
+ const response = await client.fetch(binderProjectUrl(siteUrl, projectId), { headers: SP_READ_HEADERS });
+ if (!response.ok) {
+ throw new Error(BINDER_TEXTS.errors.projectLoad(response.status));
+ }
+ return toBinderProject((await response.json()) as ISpBinderProjectRow);
+}
+
+export async function searchCatalog(
+ client: IHttpClient,
+ apiBaseUrl: string,
+ query: string,
+ page: number
+): Promise {
+ const response = await client.fetch(catalogSearchUrl(apiBaseUrl, query, page));
+ if (!response.ok) {
+ throw new Error(BINDER_TEXTS.errors.search(response.status));
+ }
+ return (await response.json()) as ICatalogPage;
+}
+
+async function mergeProject(
+ client: IHttpClient,
+ siteUrl: string,
+ itemId: number,
+ body: Record,
+ errorText: (status: number) => string
+): Promise {
+ const response = await client.fetch(projectItemUrl(siteUrl, itemId), {
+ method: 'POST',
+ headers: SP_MERGE_HEADERS,
+ body: JSON.stringify(body)
+ });
+ if (!response.ok && response.status !== NO_CONTENT) {
+ throw new Error(errorText(response.status));
+ }
+}
+
+/** Записывает в «Проекты» id и название проекта из справочника. */
+export function bindProject(
+ client: IHttpClient,
+ siteUrl: string,
+ itemId: number,
+ catalog: ICatalogProject
+): Promise {
+ return mergeProject(
+ client,
+ siteUrl,
+ itemId,
+ { UniqueId0: catalog.oid, UniqueName: catalog.name },
+ BINDER_TEXTS.errors.bind
+ );
+}
+
+/** Очищает привязку: SharePoint ждёт именно null, чтобы очистить поле. */
+export function unbindProject(client: IHttpClient, siteUrl: string, itemId: number): Promise {
+ return mergeProject(client, siteUrl, itemId, { UniqueId0: null, UniqueName: null }, BINDER_TEXTS.errors.unbind);
+}
diff --git a/src/webparts/stroyBot/pages/binder/binderRules.test.ts b/src/webparts/stroyBot/pages/binder/binderRules.test.ts
new file mode 100644
index 0000000..5178ef0
--- /dev/null
+++ b/src/webparts/stroyBot/pages/binder/binderRules.test.ts
@@ -0,0 +1,125 @@
+import {
+ canGoToNextPage,
+ canGoToPreviousPage,
+ decodeHtmlEntities,
+ formatDateRu,
+ getBinderStatusKind,
+ hasCatalogCoordinates,
+ hasProjectCoordinates,
+ isProjectBound,
+ parseProjectId,
+ toggleSelection
+} from './binderRules';
+import { ICatalogProject } from './binderTypes';
+import { BINDER_TEXTS } from './texts';
+
+const catalog = (oid: string, overrides: Partial = {}): ICatalogProject => ({
+ id: 1,
+ oid,
+ name: oid,
+ description: '',
+ address: '',
+ latitude: 43.2,
+ longitude: 76.9,
+ ...overrides
+});
+
+describe('parseProjectId', () => {
+ it('parseInt как в binder', () => {
+ expect(parseProjectId('5')).toEqual(5);
+ expect(parseProjectId('5abc')).toEqual(5);
+ expect(parseProjectId('abc')).toBeUndefined();
+ expect(parseProjectId(undefined)).toBeUndefined();
+ });
+});
+
+describe('isProjectBound', () => {
+ it('привязан, только если UniqueId0 не пустой', () => {
+ expect(isProjectBound({ id: 1, title: 'П', uniqueId: 'x' })).toBe(true);
+ expect(isProjectBound({ id: 1, title: 'П', uniqueId: '' })).toBe(false);
+ expect(isProjectBound({ id: 1, title: 'П' })).toBe(false);
+ expect(isProjectBound(undefined)).toBe(false);
+ });
+});
+
+describe('getBinderStatusKind', () => {
+ it('текущий — зелёный, архивный — золотой, остальное — серый; регистр не важен', () => {
+ expect(getBinderStatusKind('Текущий')).toEqual('current');
+ expect(getBinderStatusKind('АРХИВНЫЙ')).toEqual('archive');
+ expect(getBinderStatusKind('завершен')).toEqual('other');
+ });
+
+ it('пробелы не обрезаются (как в binder)', () => {
+ expect(getBinderStatusKind(' текущий')).toEqual('other');
+ });
+});
+
+describe('formatDateRu', () => {
+ it('дд.мм.гггг', () => {
+ expect(formatDateRu('2026-03-05T12:00:00Z')).toEqual('05.03.2026');
+ });
+});
+
+describe('decodeHtmlEntities', () => {
+ it('декодирует сущности и не превращает их в разметку', () => {
+ expect(decodeHtmlEntities('г. Астана, ул. "Кенесары" & 5')).toEqual('г. Астана, ул. "Кенесары" & 5');
+ expect(decodeHtmlEntities('<b>жирный</b>')).toEqual('жирный');
+ expect(decodeHtmlEntities('«Сункар»')).toEqual('«Сункар»');
+ });
+});
+
+describe('координаты', () => {
+ it('проект SharePoint: обе координаты, 0 — тоже координата', () => {
+ expect(hasProjectCoordinates({ id: 1, title: 'П', latitude: 0, longitude: 0 })).toBe(true);
+ expect(hasProjectCoordinates({ id: 1, title: 'П', latitude: 51 })).toBe(false);
+ });
+
+ it('справочник: проверка строго на null', () => {
+ expect(hasCatalogCoordinates(catalog('a'))).toBe(true);
+ expect(hasCatalogCoordinates(catalog('a', { latitude: null }))).toBe(false);
+ expect(hasCatalogCoordinates(catalog('a', { longitude: null }))).toBe(false);
+ });
+
+ it('формат «широта, долгота»', () => {
+ expect(BINDER_TEXTS.coordinates(43.2, 76.9)).toEqual('43.2, 76.9');
+ });
+});
+
+describe('toggleSelection', () => {
+ it('клик по другой карточке выбирает её, по выбранной — снимает выбор', () => {
+ const a = catalog('a');
+ const b = catalog('b');
+ expect(toggleSelection(undefined, a)).toBe(a);
+ expect(toggleSelection(a, b)).toBe(b);
+ expect(toggleSelection(a, catalog('a'))).toBeUndefined();
+ });
+});
+
+describe('пагинация', () => {
+ it('«←» неактивна на первой странице и во время поиска', () => {
+ expect(canGoToPreviousPage(1, false)).toBe(false);
+ expect(canGoToPreviousPage(2, false)).toBe(true);
+ expect(canGoToPreviousPage(2, true)).toBe(false);
+ });
+
+ it('«→» неактивна на последней странице и во время поиска', () => {
+ expect(canGoToNextPage(3, 3, false)).toBe(false);
+ expect(canGoToNextPage(2, 3, false)).toBe(true);
+ expect(canGoToNextPage(2, 3, true)).toBe(false);
+ });
+
+ it('подпись «{page} / {total} · всего: N»', () => {
+ expect(BINDER_TEXTS.pageInfo(2, 3, 41)).toEqual('2 / 3 · всего: 41');
+ });
+});
+
+describe('тексты сообщений', () => {
+ it('успех привязки и отвязки', () => {
+ expect(BINDER_TEXTS.bindSuccess('ЖК Сункар')).toEqual('Проект успешно привязан к "ЖК Сункар"');
+ expect(BINDER_TEXTS.unbindSuccess).toEqual('Привязка удалена');
+ });
+
+ it('строка привязки «UniqueName · UniqueId0»', () => {
+ expect(BINDER_TEXTS.boundInfo('Сункар', 'a1b2')).toEqual('Сункар · a1b2');
+ });
+});
diff --git a/src/webparts/stroyBot/pages/binder/binderRules.ts b/src/webparts/stroyBot/pages/binder/binderRules.ts
new file mode 100644
index 0000000..f0918cc
--- /dev/null
+++ b/src/webparts/stroyBot/pages/binder/binderRules.ts
@@ -0,0 +1,66 @@
+import { BinderStatusKind, IBinderProject, ICatalogProject } from './binderTypes';
+
+// Правила раздела «Привязка к справочнику» (stroy-bot-projects-binder) — чистые функции.
+
+/** projectId из адреса как в binder: parseInt, нечисло — нет проекта. */
+export function parseProjectId(value: string | undefined): number | undefined {
+ if (!value) {
+ return undefined;
+ }
+ const parsed = parseInt(value, 10);
+ return isNaN(parsed) ? undefined : parsed;
+}
+
+export function isProjectBound(project: IBinderProject | undefined): boolean {
+ return !!project?.uniqueId;
+}
+
+/** Цвет статуса как в binder: только регистр не учитывается (пробелы — учитываются). */
+export function getBinderStatusKind(status: string): BinderStatusKind {
+ const value = status.toLowerCase();
+ if (value === 'текущий') return 'current';
+ if (value === 'архивный') return 'archive';
+ return 'other';
+}
+
+/** Дата SharePoint → «дд.мм.гггг» (ru-RU), как в binder. */
+export function formatDateRu(value: string): string {
+ return new Date(value).toLocaleDateString('ru-RU');
+}
+
+/** Адрес в «Проекты» хранится с HTML-сущностями (", « …) — декодируем без вставки разметки. */
+export function decodeHtmlEntities(value: string): string {
+ const textarea = document.createElement('textarea');
+ textarea.innerHTML = value;
+ return textarea.value;
+}
+
+export function hasProjectCoordinates(project: IBinderProject): boolean {
+ return project.latitude !== undefined && project.longitude !== undefined;
+}
+
+/** Как в binder — проверка строго на null: отсутствующее поле выводится как «undefined». */
+export function hasCatalogCoordinates(project: ICatalogProject): boolean {
+ return project.latitude !== null && project.longitude !== null;
+}
+
+/** Клик по выбранной карточке снимает выбор, по другой — выбирает её. */
+export function toggleSelection(
+ selected: ICatalogProject | undefined,
+ clicked: ICatalogProject
+): ICatalogProject | undefined {
+ return selected?.oid === clicked.oid ? undefined : clicked;
+}
+
+export function canGoToPreviousPage(currentPage: number, isSearching: boolean): boolean {
+ return currentPage > 1 && !isSearching;
+}
+
+export function canGoToNextPage(currentPage: number, totalPages: number, isSearching: boolean): boolean {
+ return currentPage < totalPages && !isSearching;
+}
+
+/** Текст ошибки как в binder — message исключения. */
+export function errorMessage(error: unknown): string {
+ return error instanceof Error ? error.message : String(error);
+}
diff --git a/src/webparts/stroyBot/pages/binder/binderTypes.ts b/src/webparts/stroyBot/pages/binder/binderTypes.ts
new file mode 100644
index 0000000..b2c330d
--- /dev/null
+++ b/src/webparts/stroyBot/pages/binder/binderTypes.ts
@@ -0,0 +1,64 @@
+/** Проект из списка SharePoint «Проекты» в том виде, в каком его показывает раздел. */
+export interface IBinderProject {
+ /** Id элемента списка «Проекты». */
+ id: number;
+ title: string;
+ /** Id проекта в справочнике API (поле UniqueId0). Пусто — проект не привязан. */
+ uniqueId?: string;
+ /** Название проекта в справочнике API (поле UniqueName). */
+ uniqueName?: string;
+ description?: string;
+ /** Адрес хранится с HTML-сущностями — при выводе декодируется. */
+ address?: string;
+ /** Даты — ISO-строки SharePoint. */
+ startDate?: string;
+ endDate?: string;
+ status?: string;
+ latitude?: number;
+ longitude?: number;
+}
+
+/**
+ * Строка «Проекты» в ответе SharePoint (odata=nometadata). Пустые поля приходят как null —
+ * при разборе приводятся к undefined.
+ */
+export interface ISpBinderProjectRow {
+ Id: number;
+ Title: string;
+ UniqueId0?: string;
+ UniqueName?: string;
+ description?: string;
+ address?: string;
+ startDate?: string;
+ endDate?: string;
+ Status?: string;
+ latitude?: number;
+ longitude?: number;
+}
+
+/** Проект справочника API (`GET /Project`). */
+export interface ICatalogProject {
+ id: number;
+ oid: string;
+ name: string;
+ code?: string;
+ description: string;
+ address: string;
+ // Внешний API отдаёт null, и старый код проверял именно `!== null` — тип описывает ответ как есть.
+ // eslint-disable-next-line @rushstack/no-new-null
+ latitude: number | null;
+ // eslint-disable-next-line @rushstack/no-new-null
+ longitude: number | null;
+}
+
+/** Страница результатов справочника. */
+export interface ICatalogPage {
+ items: ICatalogProject[];
+ totalCount: number;
+ page: number;
+ pageSize: number;
+ totalPages: number;
+}
+
+/** Цвет статуса проекта: текущий — зелёный, архивный — золотой, остальные — серый. */
+export type BinderStatusKind = 'current' | 'archive' | 'other';
diff --git a/src/webparts/stroyBot/pages/binder/texts.ts b/src/webparts/stroyBot/pages/binder/texts.ts
new file mode 100644
index 0000000..85083d1
--- /dev/null
+++ b/src/webparts/stroyBot/pages/binder/texts.ts
@@ -0,0 +1,44 @@
+// Тексты раздела «Привязка к справочнику» — дословно из stroy-bot-projects-binder.
+
+/** Разделитель « · » из старой разметки (` · ` между пробелами). */
+const DOT_SEPARATOR = ' · ';
+
+export const BINDER_TEXTS = {
+ loadingProject: 'Загрузка проекта...',
+ noProjectId: 'projectId не найден в URL',
+ dismiss: 'Закрыть',
+ fields: {
+ description: 'Описание: ',
+ address: 'Адрес: ',
+ status: 'Статус: ',
+ startDate: 'Начало: ',
+ endDate: 'Окончание: ',
+ coordinates: 'Координаты: ',
+ code: 'Код: '
+ },
+ coordinates: (latitude: number | undefined, longitude: number | undefined): string => `${latitude}, ${longitude}`,
+ bound: 'Привязан',
+ unbound: 'Не привязан',
+ boundInfo: (uniqueName: string, uniqueId: string): string => `${uniqueName}${DOT_SEPARATOR}${uniqueId}`,
+ unbind: 'Отвязать',
+ searchTitleBound: 'Изменить привязку',
+ searchTitleUnbound: 'Выбор проекта из справочника',
+ searchPlaceholder: 'Поиск проекта в справочнике...',
+ clearSearch: 'Очистить',
+ searching: 'Поиск...',
+ notFound: 'Ничего не найдено',
+ bind: 'Связать',
+ saving: 'Сохранение...',
+ previousPage: '←',
+ nextPage: '→',
+ pageInfo: (page: number, totalPages: number, totalCount: number): string =>
+ `${page} / ${totalPages}${DOT_SEPARATOR}всего: ${totalCount}`,
+ bindSuccess: (name: string): string => `Проект успешно привязан к "${name}"`,
+ unbindSuccess: 'Привязка удалена',
+ errors: {
+ projectLoad: (status: number): string => `Ошибка загрузки проекта: ${status}`,
+ search: (status: number): string => `Ошибка поиска: ${status}`,
+ bind: (status: number): string => `Ошибка сохранения: ${status}`,
+ unbind: (status: number): string => `Ошибка: ${status}`
+ }
+} as const;
diff --git a/src/webparts/stroyBot/pages/binder/useBinderProject.ts b/src/webparts/stroyBot/pages/binder/useBinderProject.ts
new file mode 100644
index 0000000..a44e74a
--- /dev/null
+++ b/src/webparts/stroyBot/pages/binder/useBinderProject.ts
@@ -0,0 +1,49 @@
+import * as React from 'react';
+import { useAppServices } from '../../app/AppServicesContext';
+import { fetchBinderProject } from './binderRepository';
+import { errorMessage } from './binderRules';
+import { IBinderProject } from './binderTypes';
+import { BINDER_TEXTS } from './texts';
+
+export interface IBinderProjectState {
+ project: IBinderProject | undefined;
+ setProject: React.Dispatch>;
+ isLoading: boolean;
+ /** Показывается предупреждением; поиск по справочнику при этом всё равно работает (как в binder). */
+ projectError: string | undefined;
+}
+
+/** Проект из «Проекты» по projectId из адреса. */
+export function useBinderProject(projectId: number | undefined): IBinderProjectState {
+ const { sharePoint, settings } = useAppServices();
+ const [project, setProject] = React.useState(undefined);
+ const [isLoading, setIsLoading] = React.useState(true);
+ const [projectError, setProjectError] = React.useState(undefined);
+
+ React.useEffect(() => {
+ if (projectId === undefined) {
+ setIsLoading(false);
+ setProjectError(BINDER_TEXTS.noProjectId);
+ return undefined;
+ }
+ let isCancelled = false;
+ setIsLoading(true);
+ setProjectError(undefined);
+ fetchBinderProject(sharePoint, settings.siteUrl, projectId)
+ .then((loaded) => {
+ if (isCancelled) return;
+ setProject(loaded);
+ setIsLoading(false);
+ })
+ .catch((error: unknown) => {
+ if (isCancelled) return;
+ setProjectError(errorMessage(error));
+ setIsLoading(false);
+ });
+ return () => {
+ isCancelled = true;
+ };
+ }, [sharePoint, settings.siteUrl, projectId]);
+
+ return { project, setProject, isLoading, projectError };
+}
diff --git a/src/webparts/stroyBot/pages/binder/useCatalogSearch.ts b/src/webparts/stroyBot/pages/binder/useCatalogSearch.ts
new file mode 100644
index 0000000..d653771
--- /dev/null
+++ b/src/webparts/stroyBot/pages/binder/useCatalogSearch.ts
@@ -0,0 +1,94 @@
+import * as React from 'react';
+import { useAppServices } from '../../app/AppServicesContext';
+import { searchCatalog } from './binderRepository';
+import { errorMessage, toggleSelection } from './binderRules';
+import { ICatalogPage, ICatalogProject } from './binderTypes';
+
+/** Задержка поиска после ввода — как в binder. */
+export const SEARCH_DEBOUNCE_MS = 400;
+
+const FIRST_PAGE = 1;
+
+export interface ICatalogSearch {
+ search: string;
+ results: ICatalogProject[];
+ isSearching: boolean;
+ searchError: string | undefined;
+ selected: ICatalogProject | undefined;
+ currentPage: number;
+ totalPages: number;
+ totalCount: number;
+ changeSearch: (value: string) => void;
+ goToPage: (page: number) => void;
+ toggleSelect: (project: ICatalogProject) => void;
+ clearSelection: () => void;
+}
+
+/**
+ * Поиск по справочнику API. Первый запрос (весь справочник) — когда isEnabled станет true:
+ * в binder поиск стартовал только после загрузки проекта.
+ */
+export function useCatalogSearch(isEnabled: boolean): ICatalogSearch {
+ const { tnsuApi, settings } = useAppServices();
+ const [search, setSearch] = React.useState('');
+ const [results, setResults] = React.useState([]);
+ const [isSearching, setIsSearching] = React.useState(false);
+ const [searchError, setSearchError] = React.useState(undefined);
+ const [selected, setSelected] = React.useState(undefined);
+ const [paging, setPaging] = React.useState>({
+ page: FIRST_PAGE,
+ totalPages: 1,
+ totalCount: 0
+ });
+ const timerRef = React.useRef(undefined);
+
+ // Ответы не сверяются с последним запросом — как в binder, побеждает пришедший последним.
+ const runSearch = (query: string, page: number): void => {
+ setIsSearching(true);
+ setSearchError(undefined);
+ searchCatalog(tnsuApi, settings.apiBaseUrl, query, page)
+ .then((data) => {
+ setResults(data.items);
+ setPaging({ page: data.page, totalPages: data.totalPages, totalCount: data.totalCount });
+ setIsSearching(false);
+ })
+ .catch((error: unknown) => {
+ setSearchError(errorMessage(error));
+ setIsSearching(false);
+ });
+ };
+
+ const hasStartedRef = React.useRef(false);
+ React.useEffect(() => {
+ // Только первый запуск после загрузки проекта; дальше поиск идёт по вводу и страницам.
+ if (!isEnabled || hasStartedRef.current) return;
+ hasStartedRef.current = true;
+ runSearch('', FIRST_PAGE);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [isEnabled]);
+
+ React.useEffect(() => () => window.clearTimeout(timerRef.current), []);
+
+ const changeSearch = (value: string): void => {
+ setSearch(value);
+ setResults([]);
+ setSelected(undefined);
+ window.clearTimeout(timerRef.current);
+ timerRef.current = window.setTimeout(() => runSearch(value, FIRST_PAGE), SEARCH_DEBOUNCE_MS);
+ };
+
+ return {
+ search,
+ results,
+ isSearching,
+ searchError,
+ selected,
+ currentPage: paging.page,
+ totalPages: paging.totalPages,
+ totalCount: paging.totalCount,
+ changeSearch,
+ goToPage: (page) => runSearch(search, page),
+ toggleSelect: (project) => setSelected((prev) => toggleSelection(prev, project)),
+ clearSelection: () => setSelected(undefined)
+ };
+}
diff --git a/src/webparts/stroyBot/pages/binder/useProjectBinding.ts b/src/webparts/stroyBot/pages/binder/useProjectBinding.ts
new file mode 100644
index 0000000..c32d173
--- /dev/null
+++ b/src/webparts/stroyBot/pages/binder/useProjectBinding.ts
@@ -0,0 +1,72 @@
+import * as React from 'react';
+import { useAppServices } from '../../app/AppServicesContext';
+import { bindProject, unbindProject } from './binderRepository';
+import { errorMessage } from './binderRules';
+import { IBinderProject, ICatalogProject } from './binderTypes';
+import { BINDER_TEXTS } from './texts';
+
+export interface IProjectBinding {
+ isSaving: boolean;
+ successMessage: string | undefined;
+ saveError: string | undefined;
+ /** true — привязка сохранена (страница снимает выбор карточки). */
+ bind: (catalog: ICatalogProject) => Promise;
+ unbind: () => Promise;
+ clearSuccess: () => void;
+ clearSaveError: () => void;
+}
+
+/** «Связать» / «Отвязать» без подтверждения — как в binder. После успеха карточка проекта обновляется на месте. */
+export function useProjectBinding(
+ project: IBinderProject | undefined,
+ setProject: React.Dispatch>
+): IProjectBinding {
+ const { sharePoint, settings } = useAppServices();
+ const [isSaving, setIsSaving] = React.useState(false);
+ const [successMessage, setSuccessMessage] = React.useState(undefined);
+ const [saveError, setSaveError] = React.useState(undefined);
+
+ const save = async (write: (itemId: number) => Promise, applied: Partial): Promise => {
+ if (!project) return false;
+ setIsSaving(true);
+ setSaveError(undefined);
+ setSuccessMessage(undefined);
+ try {
+ await write(project.id);
+ setProject((prev) => (prev ? { ...prev, ...applied } : prev));
+ setIsSaving(false);
+ return true;
+ } catch (error) {
+ setIsSaving(false);
+ setSaveError(errorMessage(error));
+ return false;
+ }
+ };
+
+ const bind = async (catalog: ICatalogProject): Promise => {
+ const isSaved = await save((itemId) => bindProject(sharePoint, settings.siteUrl, itemId, catalog), {
+ uniqueId: catalog.oid,
+ uniqueName: catalog.name
+ });
+ if (isSaved) setSuccessMessage(BINDER_TEXTS.bindSuccess(catalog.name));
+ return isSaved;
+ };
+
+ const unbind = async (): Promise => {
+ const isSaved = await save((itemId) => unbindProject(sharePoint, settings.siteUrl, itemId), {
+ uniqueId: undefined,
+ uniqueName: undefined
+ });
+ if (isSaved) setSuccessMessage(BINDER_TEXTS.unbindSuccess);
+ };
+
+ return {
+ isSaving,
+ successMessage,
+ saveError,
+ bind,
+ unbind,
+ clearSuccess: () => setSuccessMessage(undefined),
+ clearSaveError: () => setSaveError(undefined)
+ };
+}