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:
@@ -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('г. Астана, ул. "Кенесары" & 5')).toEqual('г. Астана, ул. "Кенесары" & 5');
|
||||||
|
expect(decodeHtmlEntities('<b>жирный</b>')).toEqual('<b>жирный</b>');
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|
||||||
|
/** Разделитель « · » из старой разметки (` · ` между пробелами). */
|
||||||
|
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)
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user