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

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

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Тимур Городецкий
2026-10-16 08:44:57 +05:00
parent 55ce772eaa
commit e7527081b4
18 changed files with 1390 additions and 0 deletions
@@ -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>
);
};