feat: port stroy-bot-projects-binder as binder page

Add the "binder" section (?page=binder&projectId=): links a SharePoint
project to the TNSU API project catalogue by writing UniqueId0/UniqueName
to the "Проекты" list.

The class component is rewritten as function components with hooks while
keeping the legacy behaviour and texts: the same project GET with
odata=nometadata and empty odata-version, MERGE with IF-MATCH * for
bind/unbind (no confirmation), catalogue search with 400 ms debounce and
20-per-page paging, card select/deselect, status colours, ru-RU dates and
HTML-entity decoding of the address. Fluent UI is replaced with ui-kit.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Тимур Городецкий
2026-10-16 08:44:57 +05:00
parent 55ce772eaa
commit e7527081b4
18 changed files with 1390 additions and 0 deletions
+8
View File
@@ -221,6 +221,14 @@
- [ ] Сообщения успеха и ошибок - [ ] Сообщения успеха и ошибок
- [ ] Запись `UniqueId0` и `UniqueName` в «Проекты» - [ ] Запись `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.
--- ---
## Открытые вопросы ## Открытые вопросы
@@ -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;
}
}
@@ -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 <Spinner size="large" label={BINDER_TEXTS.loadingProject} block />;
}
const onBind = (selected: ICatalogProject): void => {
binding
.bind(selected)
.then((isSaved) => {
if (isSaved) catalog.clearSelection();
})
.catch(() => undefined);
};
return (
<div className={styles.page}>
{projectError && <Alert variant="warn">{projectError}</Alert>}
{project && (
<ProjectCard
project={project}
isSaving={binding.isSaving}
onUnbind={() => {
binding.unbind().catch(() => undefined);
}}
/>
)}
{binding.successMessage && (
<DismissibleAlert variant="ok" dismissLabel={BINDER_TEXTS.dismiss} onDismiss={binding.clearSuccess}>
{binding.successMessage}
</DismissibleAlert>
)}
{binding.saveError && (
<DismissibleAlert variant="err" dismissLabel={BINDER_TEXTS.dismiss} onDismiss={binding.clearSaveError}>
{binding.saveError}
</DismissibleAlert>
)}
<hr className={styles.hairline} />
<CatalogSection catalog={catalog} isBound={isProjectBound(project)} isSaving={binding.isSaving} onBind={onBind} />
</div>
);
};
@@ -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<Props> = ({
currentPage,
totalPages,
totalCount,
isSearching,
onPageChange
}) => (
<div className={styles.pagination}>
<Button
variant="secondary"
size="sm"
disabled={!canGoToPreviousPage(currentPage, isSearching)}
onClick={() => onPageChange(currentPage - 1)}
>
{BINDER_TEXTS.previousPage}
</Button>
<span className={styles.pageInfo}>{BINDER_TEXTS.pageInfo(currentPage, totalPages, totalCount)}</span>
<Button
variant="secondary"
size="sm"
disabled={!canGoToNextPage(currentPage, totalPages, isSearching)}
onClick={() => onPageChange(currentPage + 1)}
>
{BINDER_TEXTS.nextPage}
</Button>
</div>
);
@@ -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<Props> = ({ project, isSelected, isSaving, onToggle, onBind }) => {
const onBindClick = (event: React.MouseEvent<HTMLButtonElement>): void => {
// Иначе клик дойдёт до карточки и снимет выбор.
event.stopPropagation();
onBind(project);
};
return (
<div
className={isSelected ? `${styles.resultItem} ${styles.resultItemSelected}` : styles.resultItem}
onClick={() => onToggle(project)}
>
<span className={styles.resultName}>{project.name}</span>
{project.code && <FieldLine label={FIELDS.code}>{project.code}</FieldLine>}
{project.description && <FieldLine label={FIELDS.description}>{project.description}</FieldLine>}
{project.address && <FieldLine label={FIELDS.address}>{project.address}</FieldLine>}
{hasCatalogCoordinates(project) && (
<FieldLine label={FIELDS.coordinates}>
{BINDER_TEXTS.coordinates(project.latitude ?? undefined, project.longitude ?? undefined)}
</FieldLine>
)}
{isSelected && (
<div className={styles.bindAction}>
<Button variant="primary" disabled={isSaving} onClick={onBindClick}>
{isSaving ? BINDER_TEXTS.saving : BINDER_TEXTS.bind}
</Button>
</div>
)}
</div>
);
};
@@ -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<Props> = ({ catalog, isBound, isSaving, onBind }) => {
const isNothingFound = !catalog.isSearching && catalog.results.length === 0 && catalog.search.trim() !== '';
return (
<>
<h3 className={styles.sectionTitle}>
{isBound ? BINDER_TEXTS.searchTitleBound : BINDER_TEXTS.searchTitleUnbound}
</h3>
<div className={styles.searchBox}>
<div className={styles.searchInput}>
<TextInput
value={catalog.search}
onChange={catalog.changeSearch}
placeholder={BINDER_TEXTS.searchPlaceholder}
/>
</div>
{catalog.search && (
<IconButton
icon="close"
size="sm"
title={BINDER_TEXTS.clearSearch}
onClick={() => catalog.changeSearch('')}
/>
)}
</div>
{catalog.isSearching && <Spinner size="medium" label={BINDER_TEXTS.searching} />}
{catalog.searchError && <Alert variant="err">{catalog.searchError}</Alert>}
{isNothingFound && <span className={styles.emptyResults}>{BINDER_TEXTS.notFound}</span>}
<div className={styles.results}>
{catalog.results.map((project) => (
<CatalogResultCard
key={project.oid}
project={project}
isSelected={catalog.selected?.oid === project.oid}
isSaving={isSaving}
onToggle={catalog.toggleSelect}
onBind={onBind}
/>
))}
</div>
{catalog.totalCount > 0 && (
<CatalogPagination
currentPage={catalog.currentPage}
totalPages={catalog.totalPages}
totalCount={catalog.totalCount}
isSearching={catalog.isSearching}
onPageChange={catalog.goToPage}
/>
)}
</>
);
};
@@ -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<Props> = ({ variant, dismissLabel, onDismiss, children }) => (
<div className={styles.dismissibleAlert}>
<div className={styles.dismissibleAlertBody}>
<Alert variant={variant}>{children}</Alert>
</div>
<IconButton icon="close" title={dismissLabel} size="sm" onClick={onDismiss} />
</div>
);
@@ -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<Props> = ({ label, children }) => (
<div className={styles.fieldLine}>
<span className={styles.fieldLabel}>{label}</span>
{children}
</div>
);
@@ -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<BinderStatusKind, BadgeVariant> = {
current: 'success',
archive: 'warning',
other: 'muted'
};
interface Props {
project: IBinderProject;
isSaving: boolean;
onUnbind: () => void;
}
/** Карточка проекта SharePoint: поля, состояние привязки и «Отвязать». */
export const ProjectCard: React.FC<Props> = ({ project, isSaving, onUnbind }) => {
const isBound = isProjectBound(project);
return (
<div className={styles.projectCard}>
<h2 className={styles.projectTitle}>{project.title}</h2>
<div className={styles.projectFields}>
{project.description && <FieldLine label={FIELDS.description}>{project.description}</FieldLine>}
{project.address && <FieldLine label={FIELDS.address}>{decodeHtmlEntities(project.address)}</FieldLine>}
{project.status && (
<FieldLine label={FIELDS.status}>
<Badge variant={STATUS_VARIANT[getBinderStatusKind(project.status)]} size="sm">
{project.status}
</Badge>
</FieldLine>
)}
{project.startDate && <FieldLine label={FIELDS.startDate}>{formatDateRu(project.startDate)}</FieldLine>}
{project.endDate && <FieldLine label={FIELDS.endDate}>{formatDateRu(project.endDate)}</FieldLine>}
{hasProjectCoordinates(project) && (
<FieldLine label={FIELDS.coordinates}>
{BINDER_TEXTS.coordinates(project.latitude, project.longitude)}
</FieldLine>
)}
</div>
<div className={styles.bindingRow}>
<Badge variant={isBound ? 'success' : 'danger'}>{isBound ? BINDER_TEXTS.bound : BINDER_TEXTS.unbound}</Badge>
{isBound && (
<span className={styles.boundInfo}>
{BINDER_TEXTS.boundInfo(project.uniqueName ?? '', project.uniqueId ?? '')}
</span>
)}
</div>
{isBound && (
<div className={styles.unbindAction}>
<Button variant="secondary" disabled={isSaving} onClick={onUnbind}>
{BINDER_TEXTS.unbind}
</Button>
</div>
)}
</div>
);
};
@@ -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');
});
});
@@ -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<string, string> = {
Accept: 'application/json;odata=nometadata',
'odata-version': ''
};
/** Заголовки записи как в binder (написание ключей сохранено). */
export const SP_MERGE_HEADERS: Record<string, string> = {
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<IBinderProject> {
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<ICatalogPage> {
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<string, unknown>,
errorText: (status: number) => string
): Promise<void> {
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<void> {
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<void> {
return mergeProject(client, siteUrl, itemId, { UniqueId0: null, UniqueName: null }, BINDER_TEXTS.errors.unbind);
}
@@ -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> = {}): 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('г. Астана, ул. &quot;Кенесары&quot; &amp; 5')).toEqual('г. Астана, ул. "Кенесары" & 5');
expect(decodeHtmlEntities('&lt;b&gt;жирный&lt;/b&gt;')).toEqual('<b>жирный</b>');
expect(decodeHtmlEntities('&#171;Сункар&#187;')).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');
});
});
@@ -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-сущностями (&quot;, &#171; …) — декодируем без вставки разметки. */
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);
}
@@ -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';
@@ -0,0 +1,44 @@
// Тексты раздела «Привязка к справочнику» — дословно из stroy-bot-projects-binder.
/** Разделитель « · » из старой разметки (`&nbsp;·&nbsp;` между пробелами). */
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;
@@ -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<React.SetStateAction<IBinderProject | undefined>>;
isLoading: boolean;
/** Показывается предупреждением; поиск по справочнику при этом всё равно работает (как в binder). */
projectError: string | undefined;
}
/** Проект из «Проекты» по projectId из адреса. */
export function useBinderProject(projectId: number | undefined): IBinderProjectState {
const { sharePoint, settings } = useAppServices();
const [project, setProject] = React.useState<IBinderProject | undefined>(undefined);
const [isLoading, setIsLoading] = React.useState<boolean>(true);
const [projectError, setProjectError] = React.useState<string | undefined>(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 };
}
@@ -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<string>('');
const [results, setResults] = React.useState<ICatalogProject[]>([]);
const [isSearching, setIsSearching] = React.useState<boolean>(false);
const [searchError, setSearchError] = React.useState<string | undefined>(undefined);
const [selected, setSelected] = React.useState<ICatalogProject | undefined>(undefined);
const [paging, setPaging] = React.useState<Pick<ICatalogPage, 'page' | 'totalPages' | 'totalCount'>>({
page: FIRST_PAGE,
totalPages: 1,
totalCount: 0
});
const timerRef = React.useRef<number | undefined>(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<boolean>(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)
};
}
@@ -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<boolean>;
unbind: () => Promise<void>;
clearSuccess: () => void;
clearSaveError: () => void;
}
/** «Связать» / «Отвязать» без подтверждения — как в binder. После успеха карточка проекта обновляется на месте. */
export function useProjectBinding(
project: IBinderProject | undefined,
setProject: React.Dispatch<React.SetStateAction<IBinderProject | undefined>>
): IProjectBinding {
const { sharePoint, settings } = useAppServices();
const [isSaving, setIsSaving] = React.useState<boolean>(false);
const [successMessage, setSuccessMessage] = React.useState<string | undefined>(undefined);
const [saveError, setSaveError] = React.useState<string | undefined>(undefined);
const save = async (write: (itemId: number) => Promise<void>, applied: Partial<IBinderProject>): Promise<boolean> => {
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<boolean> => {
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<void> => {
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)
};
}