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 }) => ( +
+
+ {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) + }; +}