From 55ce772eaaa0b41625914427749b3726e3e5a008 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A2=D0=B8=D0=BC=D1=83=D1=80=20=D0=93=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=B4=D0=B5=D1=86=D0=BA=D0=B8=D0=B9?= Date: Thu, 15 Oct 2026 17:18:19 +0500 Subject: [PATCH] feat: port stroy-bot-project-assigner as resources page Add the "resources" section (?page=resources&projectId=): professions and companies assigned to a project, with the same lists, fields, texts and optimistic add/remove with rollback as the legacy web part. PnPjs is replaced by SharePoint REST through the app's IHttpClient so the bundle needs no new dependency: the same $select/$expand/$filter, PnP paging replaced by following odata.nextLink, adds as POST to items with the same body, deletes as POST with X-HTTP-Method DELETE and IF-MATCH *. Known legacy edge cases (temporary negative id) are kept and listed in the checklist. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/MIGRATION-CHECKLIST.md | 9 + .../pages/resources/AssignedPanel.tsx | 34 ++++ .../pages/resources/DismissibleAlert.tsx | 20 ++ .../stroyBot/pages/resources/PoolPanel.tsx | 67 +++++++ .../pages/resources/ResourcesPage.module.scss | 176 ++++++++++++++++++ .../pages/resources/ResourcesPage.tsx | 64 +++++++ .../stroyBot/pages/resources/SplitPanel.tsx | 24 +++ .../resources/resourcesRepository.test.ts | 163 ++++++++++++++++ .../pages/resources/resourcesRepository.ts | 166 +++++++++++++++++ .../pages/resources/resourcesRules.test.ts | 98 ++++++++++ .../pages/resources/resourcesRules.ts | 55 ++++++ .../pages/resources/resourcesTypes.ts | 40 ++++ .../stroyBot/pages/resources/texts.ts | 32 ++++ .../pages/resources/useAssignedActions.ts | 66 +++++++ .../pages/resources/useProjectResources.ts | 69 +++++++ 15 files changed, 1083 insertions(+) create mode 100644 src/webparts/stroyBot/pages/resources/AssignedPanel.tsx create mode 100644 src/webparts/stroyBot/pages/resources/DismissibleAlert.tsx create mode 100644 src/webparts/stroyBot/pages/resources/PoolPanel.tsx create mode 100644 src/webparts/stroyBot/pages/resources/ResourcesPage.module.scss create mode 100644 src/webparts/stroyBot/pages/resources/ResourcesPage.tsx create mode 100644 src/webparts/stroyBot/pages/resources/SplitPanel.tsx create mode 100644 src/webparts/stroyBot/pages/resources/resourcesRepository.test.ts create mode 100644 src/webparts/stroyBot/pages/resources/resourcesRepository.ts create mode 100644 src/webparts/stroyBot/pages/resources/resourcesRules.test.ts create mode 100644 src/webparts/stroyBot/pages/resources/resourcesRules.ts create mode 100644 src/webparts/stroyBot/pages/resources/resourcesTypes.ts create mode 100644 src/webparts/stroyBot/pages/resources/texts.ts create mode 100644 src/webparts/stroyBot/pages/resources/useAssignedActions.ts create mode 100644 src/webparts/stroyBot/pages/resources/useProjectResources.ts diff --git a/docs/MIGRATION-CHECKLIST.md b/docs/MIGRATION-CHECKLIST.md index 920b4c1..571f724 100644 --- a/docs/MIGRATION-CHECKLIST.md +++ b/docs/MIGRATION-CHECKLIST.md @@ -201,6 +201,15 @@ - [ ] Оптимистичное добавление / удаление с откатом и ошибкой - [ ] На узких экранах колонки друг под другом +🐞 Удаление ещё не сохранённой записи (временный отрицательный id) уходит на сервер, падает и откатывается с ошибкой. 🐞 Если запись удалили до ответа на добавление, она пропадает из списка, хотя на сервере создана. 🐞 Два добавления в одну миллисекунду получают одинаковый временный id. + +**Сознательные отличия от assigner:** +- PnPjs заменён на SharePoint REST с теми же `$select` / `$expand` / `$filter`. Постраничная загрузка PnP (`for await`) → проход по `@odata.nextLink` / `odata.nextLink` до конца (без `$top`, страницы по умолчанию — 100). +- Добавление: `POST …/items` с тем же телом (`Title`, `projectIdId`, `professionIdId` / `companyIdId`), заголовки `Accept` и `Content-Type: application/json;odata=nometadata` + пустой `odata-version` (как в binder). Удаление: `POST …/items({id})` с `X-HTTP-Method: DELETE` и `IF-MATCH: *` — так удаляет сам PnP. +- Текст после префикса ошибки («Ошибка загрузки данных: …» и т.п.) — сообщение `HttpError` (`{status} {statusText}: {ответ сервера}`), а не формат ошибки PnP. +- Счётчик во вкладке — часть подписи («Профессии (0)»): `count` в Tabs ui-kit скрывает ноль. +- Кнопки «+» / «−» — `Button` ui-kit (с подсказками «Добавить» / «Удалить»), сообщение об ошибке закрывается крестиком рядом с `Alert`. + --- ## 8. Привязка к справочнику (stroy-bot-projects-binder) — `?page=binder&projectId=` diff --git a/src/webparts/stroyBot/pages/resources/AssignedPanel.tsx b/src/webparts/stroyBot/pages/resources/AssignedPanel.tsx new file mode 100644 index 0000000..4be7163 --- /dev/null +++ b/src/webparts/stroyBot/pages/resources/AssignedPanel.tsx @@ -0,0 +1,34 @@ +import * as React from 'react'; +import { Button } from '@tnsu/ui-kit'; +import { IAssignedResource } from './resourcesTypes'; +import { RESOURCES_TEXTS } from './texts'; +import styles from './ResourcesPage.module.scss'; + +const TEXTS = RESOURCES_TEXTS.assigned; + +interface Props { + items: IAssignedResource[]; + onRemove: (itemId: number) => void; +} + +/** Правая колонка «Привязаны к проекту». */ +export const AssignedPanel: React.FC = ({ items, onRemove }) => ( +
+
{TEXTS.title}
+ {items.length === 0 &&
{TEXTS.empty}
} + {items.map((item) => ( +
+ {item.title} + +
+ ))} +
+); diff --git a/src/webparts/stroyBot/pages/resources/DismissibleAlert.tsx b/src/webparts/stroyBot/pages/resources/DismissibleAlert.tsx new file mode 100644 index 0000000..5cd6a8a --- /dev/null +++ b/src/webparts/stroyBot/pages/resources/DismissibleAlert.tsx @@ -0,0 +1,20 @@ +import * as React from 'react'; +import { Alert, IconButton } from '@tnsu/ui-kit'; +import styles from './ResourcesPage.module.scss'; + +interface Props { + variant: 'warn' | 'ok' | 'err' | 'info'; + dismissLabel: string; + onDismiss: () => void; + children: React.ReactNode; +} + +// Alert из ui-kit не закрывается, а MessageBar в assigner закрывался крестиком — добавляем его рядом. +export const DismissibleAlert: React.FC = ({ variant, dismissLabel, onDismiss, children }) => ( +
+
+ {children} +
+ +
+); diff --git a/src/webparts/stroyBot/pages/resources/PoolPanel.tsx b/src/webparts/stroyBot/pages/resources/PoolPanel.tsx new file mode 100644 index 0000000..0ce5699 --- /dev/null +++ b/src/webparts/stroyBot/pages/resources/PoolPanel.tsx @@ -0,0 +1,67 @@ +import * as React from 'react'; +import { Badge, Button, IconButton, TextInput } from '@tnsu/ui-kit'; +import { assignedReferenceIds, filterPool, poolEmptyMessage } from './resourcesRules'; +import { IAssignedResource, IResourceItem } from './resourcesTypes'; +import { RESOURCES_TEXTS } from './texts'; +import styles from './ResourcesPage.module.scss'; + +const TEXTS = RESOURCES_TEXTS.pool; + +interface Props { + items: IResourceItem[]; + assigned: IAssignedResource[]; + onAdd: (item: IResourceItem) => void; +} + +/** Левая колонка «Доступные»: поиск по справочнику, уже привязанные помечены и не добавляются. */ +export const PoolPanel: React.FC = ({ items, assigned, onAdd }) => { + const [query, setQuery] = React.useState(''); + const filtered = filterPool(items, query); + const addedIds = assignedReferenceIds(assigned); + + return ( +
+
{TEXTS.title}
+ +
+
+ +
+ {query && setQuery('')} />} +
+ +
+ {filtered.length === 0 &&
{poolEmptyMessage(query)}
} + + {filtered.map((item) => { + const isAlreadyAdded = addedIds.has(item.id); + return ( +
+ + {item.title} + {isAlreadyAdded && ( + + + {TEXTS.alreadyAdded} + + + )} + + {!isAlreadyAdded && ( + + )} +
+ ); + })} +
+
+ ); +}; diff --git a/src/webparts/stroyBot/pages/resources/ResourcesPage.module.scss b/src/webparts/stroyBot/pages/resources/ResourcesPage.module.scss new file mode 100644 index 0000000..58400e8 --- /dev/null +++ b/src/webparts/stroyBot/pages/resources/ResourcesPage.module.scss @@ -0,0 +1,176 @@ +@import '../../../../styles/design-tokens'; + +// Длинный справочник прокручивается внутри колонки — как в assigner. +$pool-max-height: 440px; +// Минимальная высота строки — чтобы строки с кнопкой и без неё были одной высоты. +$item-row-min-height: 40px; +// Вертикальный отступ строки списка — как в assigner (между $sp-2 и $sp-3). +$item-row-padding-y: 10px; + +.page { + display: flex; + flex-direction: column; + gap: $sp-4; + width: 100%; + line-height: 1.55; +} + +.projectHeader { + box-shadow: $sh-2; + @include page-header; +} + +.projectTitle { + position: relative; + z-index: 1; + margin: 0; + font-family: $font-serif; + font-size: $fs-page-title; + font-weight: 600; + color: $white; +} + +.dismissibleAlert { + display: flex; + align-items: flex-start; + gap: $sp-2; +} + +.dismissibleAlertBody { + flex: 1; + min-width: 0; +} + +.tabContent { + padding-top: $sp-1; +} + +.splitColumns { + display: flex; + align-items: flex-start; +} + +.splitLeft { + flex: 1; + min-width: 0; + padding-right: $sp-5; + border-right: 1px solid $gray-200; +} + +.splitRight { + flex: 1; + min-width: 0; + padding-left: $sp-5; +} + +.panelColumn { + min-width: 0; +} + +.panelTitle { + position: relative; + margin: 0 0 $sp-3; + padding-bottom: $sp-2; + font-size: $fs-overline; + font-weight: 600; + letter-spacing: 1.6px; + text-transform: uppercase; + color: $gray-400; + + &::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + right: 0; + @include hairline; + } +} + +.searchBox { + display: flex; + align-items: center; + gap: $sp-1; + margin-bottom: $sp-2; +} + +.searchInput { + flex: 1; + min-width: 0; +} + +.poolScrollArea { + max-height: $pool-max-height; + overflow-y: auto; + border: 1px solid $gray-200; + border-radius: $r-sm; + background: $white; +} + +.itemRow { + display: flex; + align-items: center; + justify-content: space-between; + gap: $sp-2; + min-height: $item-row-min-height; + padding: $item-row-padding-y $sp-3; + border-bottom: 1px solid $gray-100; + transition: background 0.12s; + + &:last-child { + border-bottom: none; + } + + &:hover { + background: $gold-50; + } +} + +.itemName, +.itemNameMuted { + flex: 1; + font-size: $fs-body; + word-break: break-word; +} + +.itemName { + font-weight: 500; + color: $gray-700; +} + +.itemNameMuted { + color: $gray-400; +} + +.alreadyAddedTag { + margin-left: $sp-2; + vertical-align: middle; +} + +.emptyMsg { + padding: $sp-4 $sp-3; + font-size: $fs-sm; + font-style: italic; + text-align: center; + color: $gray-400; +} + +@include below($bp-md) { + .splitColumns { + flex-direction: column; + } + + .splitLeft { + width: 100%; + padding-right: 0; + padding-bottom: $sp-4; + margin-bottom: $sp-4; + border-right: none; + border-bottom: 1px solid $gray-200; + } + + .splitRight { + width: 100%; + padding-left: 0; + } +} diff --git a/src/webparts/stroyBot/pages/resources/ResourcesPage.tsx b/src/webparts/stroyBot/pages/resources/ResourcesPage.tsx new file mode 100644 index 0000000..61b919c --- /dev/null +++ b/src/webparts/stroyBot/pages/resources/ResourcesPage.tsx @@ -0,0 +1,64 @@ +import * as React from 'react'; +import { Alert, ITab, Spinner, Tabs } from '@tnsu/ui-kit'; +import { useRouter } from '../../app/routing/RouterContext'; +import { useProjectResources } from './useProjectResources'; +import { SplitPanel } from './SplitPanel'; +import { DismissibleAlert } from './DismissibleAlert'; +import { ResourceKind } from './resourcesTypes'; +import { RESOURCES_TEXTS } from './texts'; +import styles from './ResourcesPage.module.scss'; + +/** Ресурсы проекта (бывший stroy-bot-project-assigner): профессии и компании, привязанные к проекту. */ +export const ResourcesPage: React.FC = () => { + const { route } = useRouter(); + const projectId = route.projectId; + const resources = useProjectResources(projectId); + const [activeTab, setActiveTab] = React.useState('professions'); + + if (!projectId) { + return {RESOURCES_TEXTS.noProject}; + } + + if (resources.isLoading) { + return ; + } + + // Счётчик — часть подписи, а не count из ui-kit: тот прячет ноль, а в assigner было «Профессии (0)». + const tabs: ITab[] = [ + { id: 'professions', label: RESOURCES_TEXTS.tabs.professions(resources.assigned.professions.length) }, + { id: 'companies', label: RESOURCES_TEXTS.tabs.companies(resources.assigned.companies.length) } + ]; + + return ( +
+ {resources.projectName && ( +
+

{resources.projectName}

+
+ )} + + {resources.error && ( + + {resources.error} + + )} + + setActiveTab(id as ResourceKind)} /> + +
+ {/* key: при смене вкладки поиск сбрасывается — Pivot в assigner размонтировал неактивную вкладку. */} + { + resources.add(activeTab, item).catch(() => undefined); + }} + onRemove={(itemId) => { + resources.remove(activeTab, itemId).catch(() => undefined); + }} + /> +
+
+ ); +}; diff --git a/src/webparts/stroyBot/pages/resources/SplitPanel.tsx b/src/webparts/stroyBot/pages/resources/SplitPanel.tsx new file mode 100644 index 0000000..8f8bb66 --- /dev/null +++ b/src/webparts/stroyBot/pages/resources/SplitPanel.tsx @@ -0,0 +1,24 @@ +import * as React from 'react'; +import { PoolPanel } from './PoolPanel'; +import { AssignedPanel } from './AssignedPanel'; +import { IAssignedResource, IResourceItem } from './resourcesTypes'; +import styles from './ResourcesPage.module.scss'; + +interface Props { + pool: IResourceItem[]; + assigned: IAssignedResource[]; + onAdd: (item: IResourceItem) => void; + onRemove: (itemId: number) => void; +} + +/** Две колонки вкладки: «Доступные» слева, «Привязаны к проекту» справа (на узком экране — друг под другом). */ +export const SplitPanel: React.FC = ({ pool, assigned, onAdd, onRemove }) => ( +
+
+ +
+
+ +
+
+); diff --git a/src/webparts/stroyBot/pages/resources/resourcesRepository.test.ts b/src/webparts/stroyBot/pages/resources/resourcesRepository.test.ts new file mode 100644 index 0000000..9402936 --- /dev/null +++ b/src/webparts/stroyBot/pages/resources/resourcesRepository.test.ts @@ -0,0 +1,163 @@ +import { + addAssignedResource, + assignedItemsUrl, + assignedItemUrl, + deleteAssignedResource, + fetchAllItems, + fetchProjectResources, + poolItemsUrl, + projectTitleUrl +} from './resourcesRepository'; +import { createFakeHttpClient } from '../../shared/testing/fakeHttpClient'; + +const SITE = 'https://tnsukz.sharepoint.com/sites/stroy'; +const items = (title: string): string => `${SITE}/_api/web/lists/getbytitle('${title}')/items`; + +describe('адреса запросов (1:1 с PnP-запросами stroy-bot-project-assigner)', () => { + it('название проекта: getById(Number(projectId)).select(Title)', () => { + expect(projectTitleUrl(SITE, '5')).toEqual(`${items('Проекты')}(5)?$select=Title`); + }); + + it('справочники: select(Id, Title)', () => { + expect(poolItemsUrl(SITE, 'professions')).toEqual(`${items('Профессии')}?$select=Id,Title`); + expect(poolItemsUrl(SITE, 'companies')).toEqual(`${items('Компании')}?$select=Id,Title`); + }); + + it('привязки: select + expand lookup + filter по projectIdId', () => { + expect(assignedItemsUrl(SITE, 'professions', '5')).toEqual( + `${items('Профессии по проекту')}?$select=Id,Title,professionId/Id&$expand=professionId&$filter=projectIdId eq 5` + ); + expect(assignedItemsUrl(SITE, 'companies', '5')).toEqual( + `${items('Компании по проекту')}?$select=Id,Title,companyId/Id&$expand=companyId&$filter=projectIdId eq 5` + ); + }); + + it('элемент привязки по id', () => { + expect(assignedItemUrl(SITE, 'companies', 77)).toEqual(`${items('Компании по проекту')}(77)`); + }); +}); + +describe('fetchAllItems', () => { + it('проходит по @odata.nextLink до последней страницы', async () => { + const next = `${items('Профессии')}?%24skiptoken=Paged%3dTRUE%26p_ID%3d100&%24select=Id,Title`; + const client = createFakeHttpClient([ + { match: 'skiptoken', response: { body: { value: [{ Id: 2, Title: 'Б' }] } } }, + { + match: "getbytitle('Профессии')", + response: { body: { value: [{ Id: 1, Title: 'А' }], '@odata.nextLink': next } } + } + ]); + + const result = await fetchAllItems(client, poolItemsUrl(SITE, 'professions')); + + expect(result).toEqual([ + { Id: 1, Title: 'А' }, + { Id: 2, Title: 'Б' } + ]); + expect(client.requests.map((r) => r.url)).toEqual([poolItemsUrl(SITE, 'professions'), next]); + }); + + it('понимает и odata.nextLink (verbose/nometadata)', async () => { + const client = createFakeHttpClient([ + { match: 'page2', response: { body: { value: [{ Id: 2 }] } } }, + { match: 'page1', response: { body: { value: [{ Id: 1 }], 'odata.nextLink': `${SITE}/page2` } } } + ]); + expect(await fetchAllItems(client, `${SITE}/page1`)).toEqual([{ Id: 1 }, { Id: 2 }]); + }); +}); + +describe('fetchProjectResources', () => { + it('собирает проект, справочники и привязки; пустой lookup → 0', async () => { + const client = createFakeHttpClient([ + { match: "getbytitle('Проекты')", response: { body: { Title: 'ЖК Сункар' } } }, + { match: "getbytitle('Профессии')", response: { body: { value: [{ Id: 12, Title: 'Бетонщик' }] } } }, + { match: "getbytitle('Компании')", response: { body: { value: [{ Id: 3, Title: 'ТОО Строй' }] } } }, + { + match: "getbytitle('Профессии по проекту')", + response: { body: { value: [{ Id: 100, Title: 'Бетонщик', professionId: { Id: 12 } }] } } + }, + { + match: "getbytitle('Компании по проекту')", + response: { body: { value: [{ Id: 200, Title: 'Без ссылки', companyId: null }] } } + } + ]); + + const result = await fetchProjectResources(client, SITE, '5'); + + expect(result).toEqual({ + projectName: 'ЖК Сункар', + pools: { professions: [{ id: 12, title: 'Бетонщик' }], companies: [{ id: 3, title: 'ТОО Строй' }] }, + assigned: { + professions: [{ id: 100, title: 'Бетонщик', referenceId: 12 }], + companies: [{ id: 200, title: 'Без ссылки', referenceId: 0 }] + } + }); + expect(client.requests).toHaveLength(5); + }); + + it('ошибка любого запроса пробрасывается', async () => { + const client = createFakeHttpClient([ + { match: "getbytitle('Проекты')", response: { status: 404 } }, + { match: '', response: { body: { value: [] } } } + ]); + await expect(fetchProjectResources(client, SITE, '5')).rejects.toThrow('404'); + }); +}); + +describe('addAssignedResource', () => { + it('профессия: POST в items с телом как в assigner, возвращает Id', async () => { + const client = createFakeHttpClient([{ match: 'items', response: { status: 201, body: { Id: 101 } } }]); + + const id = await addAssignedResource(client, SITE, 'professions', '5', { id: 12, title: 'Бетонщик' }); + + expect(id).toEqual(101); + expect(client.requests[0]).toEqual({ + url: items('Профессии по проекту'), + request: { + method: 'POST', + headers: { + Accept: 'application/json;odata=nometadata', + 'Content-Type': 'application/json;odata=nometadata', + 'odata-version': '' + }, + body: '{"Title":"Бетонщик","projectIdId":5,"professionIdId":12}' + } + }); + }); + + it('компания: поле companyIdId', async () => { + const client = createFakeHttpClient([{ match: 'items', response: { status: 201, body: { Id: 7 } } }]); + + await addAssignedResource(client, SITE, 'companies', '5', { id: 3, title: 'ТОО Строй' }); + + expect(client.requests[0].url).toEqual(items('Компании по проекту')); + expect(client.requests[0].request.body).toEqual('{"Title":"ТОО Строй","projectIdId":5,"companyIdId":3}'); + }); + + it('ошибка сервера пробрасывается', async () => { + const client = createFakeHttpClient([{ match: 'items', response: { status: 500, body: 'boom' } }]); + await expect(addAssignedResource(client, SITE, 'companies', '5', { id: 3, title: 'X' })).rejects.toThrow( + '500' + ); + }); +}); + +describe('deleteAssignedResource', () => { + it('POST с X-HTTP-Method: DELETE и IF-MATCH: *', async () => { + const client = createFakeHttpClient([{ match: 'items', response: { status: 200 } }]); + + await deleteAssignedResource(client, SITE, 'professions', 100); + + expect(client.requests[0]).toEqual({ + url: `${items('Профессии по проекту')}(100)`, + request: { method: 'POST', headers: { 'X-HTTP-Method': 'DELETE', 'IF-MATCH': '*' } } + }); + }); + + it('временный отрицательный id уходит на сервер как есть и падает', async () => { + const client = createFakeHttpClient([{ match: 'items', response: { status: 404 } }]); + + await expect(deleteAssignedResource(client, SITE, 'companies', -1700000000000)).rejects.toThrow('404'); + expect(client.requests[0].url).toEqual(`${items('Компании по проекту')}(-1700000000000)`); + }); +}); diff --git a/src/webparts/stroyBot/pages/resources/resourcesRepository.ts b/src/webparts/stroyBot/pages/resources/resourcesRepository.ts new file mode 100644 index 0000000..9b2a18d --- /dev/null +++ b/src/webparts/stroyBot/pages/resources/resourcesRepository.ts @@ -0,0 +1,166 @@ +import { ensureOk, IHttpClient } from '../../services/http/IHttpClient'; +import { getJson, listItemsUrl } from '../../shared/sharepoint/spRest'; +import { LISTS, ListTitle } from '../../config/lists'; +import { + IAssignedResource, + IProjectResources, + IResourceItem, + ISpAssignedRow, + ISpResourceRow, + ResourceKind +} from './resourcesTypes'; + +// Запросы раздела — 1:1 со stroy-bot-project-assigner (useProjectResources). +// Там был PnPjs v4; здесь те же запросы через SharePoint REST: +// - fetchAll (async-итератор PnP) → проход по odata.nextLink до конца; +// - items.add → POST в items с тем же телом; +// - item.delete → POST с X-HTTP-Method: DELETE и IF-MATCH: * (так делает сам PnP). + +/** Поле lookup на проект в списках привязок (в $filter — `{поле}Id`). */ +const PROJECT_LOOKUP_ID_FIELD = 'projectIdId'; + +interface IResourceListConfig { + /** Справочник: «Профессии» / «Компании». */ + poolList: ListTitle; + /** Привязки к проекту: «Профессии по проекту» / «Компании по проекту». */ + assignedList: ListTitle; + /** Lookup на справочник: в $select/$expand читается `{поле}/Id`, при записи — `{поле}Id`. */ + lookupField: 'professionId' | 'companyId'; +} + +export const RESOURCE_LISTS: Record = { + professions: { + poolList: LISTS.professions, + assignedList: LISTS.professionsByProject, + lookupField: 'professionId' + }, + companies: { + poolList: LISTS.companies, + assignedList: LISTS.companiesByProject, + lookupField: 'companyId' + } +}; + +/** + * Заголовки записи. odata=nometadata — тело без `__metadata` (как отправляет PnP v4); + * пустой odata-version отключает OData 4.0 от SPHttpClient — тот же приём, + * что в stroy-bot-projects-binder, проверенный на этом сайте. + */ +export const SP_WRITE_HEADERS: Record = { + Accept: 'application/json;odata=nometadata', + 'Content-Type': 'application/json;odata=nometadata', + 'odata-version': '' +}; + +export const SP_DELETE_HEADERS: Record = { + 'X-HTTP-Method': 'DELETE', + 'IF-MATCH': '*' +}; + +export function projectTitleUrl(siteUrl: string, projectId: string): string { + return `${listItemsUrl(siteUrl, LISTS.projects)}(${Number(projectId)})?$select=Title`; +} + +export function poolItemsUrl(siteUrl: string, kind: ResourceKind): string { + return `${listItemsUrl(siteUrl, RESOURCE_LISTS[kind].poolList)}?$select=Id,Title`; +} + +export function assignedItemsUrl(siteUrl: string, kind: ResourceKind, projectId: string): string { + const { assignedList, lookupField } = RESOURCE_LISTS[kind]; + return ( + `${listItemsUrl(siteUrl, assignedList)}` + + `?$select=Id,Title,${lookupField}/Id` + + `&$expand=${lookupField}` + + `&$filter=${PROJECT_LOOKUP_ID_FIELD} eq ${projectId}` + ); +} + +export function assignedItemUrl(siteUrl: string, kind: ResourceKind, itemId: number): string { + return `${listItemsUrl(siteUrl, RESOURCE_LISTS[kind].assignedList)}(${itemId})`; +} + +interface ISpPage { + value: T[]; + 'odata.nextLink'?: string; + '@odata.nextLink'?: string; +} + +/** Все страницы коллекции: SharePoint отдаёт по 100 строк и ссылку на следующую страницу. */ +export async function fetchAllItems(client: IHttpClient, url: string): Promise { + const items: T[] = []; + let nextUrl: string | undefined = url; + while (nextUrl) { + const page: ISpPage = await getJson>(client, nextUrl); + items.push(...page.value); + nextUrl = page['@odata.nextLink'] ?? page['odata.nextLink']; + } + return items; +} + +function toAssigned(row: ISpAssignedRow, kind: ResourceKind): IAssignedResource { + const lookup = row[RESOURCE_LISTS[kind].lookupField]; + return { id: row.Id, title: row.Title, referenceId: lookup?.Id ?? 0 }; +} + +function toResourceItem(row: ISpResourceRow): IResourceItem { + return { id: row.Id, title: row.Title }; +} + +/** Название проекта, оба справочника и обе привязки — параллельно, как в assigner. */ +export async function fetchProjectResources( + client: IHttpClient, + siteUrl: string, + projectId: string +): Promise { + const [project, professions, companies, assignedProfessions, assignedCompanies] = await Promise.all([ + getJson<{ Title: string }>(client, projectTitleUrl(siteUrl, projectId)), + fetchAllItems(client, poolItemsUrl(siteUrl, 'professions')), + fetchAllItems(client, poolItemsUrl(siteUrl, 'companies')), + fetchAllItems(client, assignedItemsUrl(siteUrl, 'professions', projectId)), + fetchAllItems(client, assignedItemsUrl(siteUrl, 'companies', projectId)) + ]); + + return { + projectName: project.Title, + pools: { professions: professions.map(toResourceItem), companies: companies.map(toResourceItem) }, + assigned: { + professions: assignedProfessions.map((row) => toAssigned(row, 'professions')), + companies: assignedCompanies.map((row) => toAssigned(row, 'companies')) + } + }; +} + +/** Создаёт привязку и возвращает id новой записи. */ +export async function addAssignedResource( + client: IHttpClient, + siteUrl: string, + kind: ResourceKind, + projectId: string, + item: IResourceItem +): Promise { + const body = { + Title: item.title, + [PROJECT_LOOKUP_ID_FIELD]: Number(projectId), + [`${RESOURCE_LISTS[kind].lookupField}Id`]: item.id + }; + const response = await ensureOk( + await client.fetch(listItemsUrl(siteUrl, RESOURCE_LISTS[kind].assignedList), { + method: 'POST', + headers: SP_WRITE_HEADERS, + body: JSON.stringify(body) + }) + ); + const created = (await response.json()) as { Id: number }; + return created.Id; +} + +export async function deleteAssignedResource( + client: IHttpClient, + siteUrl: string, + kind: ResourceKind, + itemId: number +): Promise { + await ensureOk( + await client.fetch(assignedItemUrl(siteUrl, kind, itemId), { method: 'POST', headers: SP_DELETE_HEADERS }) + ); +} diff --git a/src/webparts/stroyBot/pages/resources/resourcesRules.test.ts b/src/webparts/stroyBot/pages/resources/resourcesRules.test.ts new file mode 100644 index 0000000..ee9f930 --- /dev/null +++ b/src/webparts/stroyBot/pages/resources/resourcesRules.test.ts @@ -0,0 +1,98 @@ +import { + appendAssigned, + assignedReferenceIds, + createTempId, + errorText, + filterPool, + poolEmptyMessage, + removeAssigned, + replaceTempId +} from './resourcesRules'; +import { IAssignedResource } from './resourcesTypes'; + +const pool = [ + { id: 1, title: 'Бетонщик' }, + { id: 2, title: 'Арматурщик' }, + { id: 3, title: 'Сварщик' } +]; + +describe('filterPool', () => { + it('пустой запрос или пробелы — весь список', () => { + expect(filterPool(pool, '')).toEqual(pool); + expect(filterPool(pool, ' ')).toEqual(pool); + }); + + it('подстрока без учёта регистра', () => { + expect(filterPool(pool, 'ЩИК').map((item) => item.id)).toEqual([1, 2, 3]); + expect(filterPool(pool, 'свар').map((item) => item.id)).toEqual([3]); + }); + + it('запрос не обрезается — пробел по краям участвует в поиске (как в assigner)', () => { + expect(filterPool(pool, ' свар')).toEqual([]); + }); +}); + +describe('poolEmptyMessage', () => { + it('с текстом поиска — «Ничего не найдено», без — «Нет элементов»', () => { + expect(poolEmptyMessage('xyz')).toEqual('Ничего не найдено'); + expect(poolEmptyMessage(' ')).toEqual('Ничего не найдено'); + expect(poolEmptyMessage('')).toEqual('Нет элементов'); + }); +}); + +describe('assignedReferenceIds («уже добавлено»)', () => { + it('по id справочника, а не по id привязки', () => { + const ids = assignedReferenceIds([ + { id: 100, title: 'Бетонщик', referenceId: 1 }, + { id: -5, title: 'Сварщик', referenceId: 3 } + ]); + expect(ids.has(1)).toBe(true); + expect(ids.has(3)).toBe(true); + expect(ids.has(100)).toBe(false); + expect(ids.has(2)).toBe(false); + }); +}); + +describe('оптимистичное добавление / удаление', () => { + const saved: IAssignedResource = { id: 100, title: 'Бетонщик', referenceId: 1 }; + + it('временный id — минус текущее время', () => { + expect(createTempId(1700000000000)).toEqual(-1700000000000); + expect(createTempId()).toBeLessThan(0); + }); + + it('добавление: временная запись в конец, затем замена на настоящий id', () => { + const tempId = createTempId(1700000000000); + const optimistic = appendAssigned([saved], { id: tempId, title: 'Сварщик', referenceId: 3 }); + expect(optimistic.map((item) => item.id)).toEqual([100, tempId]); + + const confirmed = replaceTempId(optimistic, tempId, 101); + expect(confirmed).toEqual([saved, { id: 101, title: 'Сварщик', referenceId: 3 }]); + expect(optimistic[1].id).toEqual(tempId); + }); + + it('откат добавления — временная запись убирается', () => { + const tempId = createTempId(1700000000000); + const optimistic = appendAssigned([saved], { id: tempId, title: 'Сварщик', referenceId: 3 }); + expect(removeAssigned(optimistic, tempId)).toEqual([saved]); + }); + + it('запись убрали до ответа сервера — замена id ничего не возвращает', () => { + const tempId = createTempId(1700000000000); + expect(replaceTempId([saved], tempId, 101)).toEqual([saved]); + }); + + it('удаление не мутирует исходный список (он же снимок для отката)', () => { + const snapshot = [saved]; + expect(removeAssigned(snapshot, 100)).toEqual([]); + expect(snapshot).toEqual([saved]); + }); +}); + +describe('errorText', () => { + it('префикс + message', () => { + expect(errorText('Ошибка удаления компании: ', new Error('404 Not Found'))).toEqual( + 'Ошибка удаления компании: 404 Not Found' + ); + }); +}); diff --git a/src/webparts/stroyBot/pages/resources/resourcesRules.ts b/src/webparts/stroyBot/pages/resources/resourcesRules.ts new file mode 100644 index 0000000..e4b23ce --- /dev/null +++ b/src/webparts/stroyBot/pages/resources/resourcesRules.ts @@ -0,0 +1,55 @@ +import { IAssignedResource, IResourceItem } from './resourcesTypes'; +import { RESOURCES_TEXTS } from './texts'; + +// Правила раздела «Ресурсы проекта» (stroy-bot-project-assigner) — чистые функции. + +/** + * Поиск по «Доступные»: пустой (или из одних пробелов) запрос — весь список, + * иначе подстрока без учёта регистра. Как в assigner, сам запрос при поиске не обрезается. + */ +export function filterPool(items: IResourceItem[], query: string): IResourceItem[] { + if (query.trim() === '') { + return items; + } + const needle = query.toLowerCase(); + return items.filter((item) => item.title.toLowerCase().indexOf(needle) !== -1); +} + +/** Пустой список «Доступные»: при любом введённом тексте — «Ничего не найдено». */ +export function poolEmptyMessage(query: string): string { + return query ? RESOURCES_TEXTS.pool.notFound : RESOURCES_TEXTS.pool.noItems; +} + +/** Id элементов справочника, которые уже привязаны к проекту (метка «уже добавлено»). */ +export function assignedReferenceIds(assigned: IAssignedResource[]): Set { + return new Set(assigned.map((item) => item.referenceId)); +} + +/** + * Временный id для оптимистичного добавления — как в assigner, `-(Date.now())`. + * Два добавления в одну миллисекунду получат одинаковый id — перенесено как есть. + */ +export function createTempId(now: number = Date.now()): number { + return -now; +} + +export function appendAssigned(list: IAssignedResource[], item: IAssignedResource): IAssignedResource[] { + return [...list, item]; +} + +/** + * Сервер создал запись — временный id меняется на настоящий. Если запись уже убрали + * из списка (удаление до ответа сервера), ничего не меняется — как в assigner. + */ +export function replaceTempId(list: IAssignedResource[], tempId: number, realId: number): IAssignedResource[] { + return list.map((item) => (item.id === tempId ? { ...item, id: realId } : item)); +} + +export function removeAssigned(list: IAssignedResource[], itemId: number): IAssignedResource[] { + return list.filter((item) => item.id !== itemId); +} + +/** Текст ошибки как в assigner: префикс + message исключения. */ +export function errorText(prefix: string, error: unknown): string { + return prefix + (error instanceof Error ? error.message : String(error)); +} diff --git a/src/webparts/stroyBot/pages/resources/resourcesTypes.ts b/src/webparts/stroyBot/pages/resources/resourcesTypes.ts new file mode 100644 index 0000000..41348f1 --- /dev/null +++ b/src/webparts/stroyBot/pages/resources/resourcesTypes.ts @@ -0,0 +1,40 @@ +/** Вкладка раздела: профессии или компании. */ +export type ResourceKind = 'professions' | 'companies'; + +/** Элемент справочника «Профессии» / «Компании». */ +export interface IResourceItem { + id: number; + title: string; +} + +/** Элемент списка «Профессии по проекту» / «Компании по проекту». */ +export interface IAssignedResource { + /** Id элемента списка привязок. Пока запись создаётся — временный отрицательный id. */ + id: number; + title: string; + /** Id элемента справочника (lookup professionId / companyId); 0 — lookup пустой. */ + referenceId: number; +} + +export type ResourceRecord = Record; + +/** Всё, что раздел загружает при открытии. */ +export interface IProjectResources { + projectName: string; + pools: ResourceRecord; + assigned: ResourceRecord; +} + +/** Строка справочника в ответе SharePoint. */ +export interface ISpResourceRow { + Id: number; + Title: string; +} + +/** Строка списка привязок в ответе SharePoint (lookup раскрыт через $expand; пустой lookup может прийти как null). */ +export interface ISpAssignedRow { + Id: number; + Title: string; + professionId?: { Id: number }; + companyId?: { Id: number }; +} diff --git a/src/webparts/stroyBot/pages/resources/texts.ts b/src/webparts/stroyBot/pages/resources/texts.ts new file mode 100644 index 0000000..5bdefb9 --- /dev/null +++ b/src/webparts/stroyBot/pages/resources/texts.ts @@ -0,0 +1,32 @@ +// Тексты раздела «Ресурсы проекта» — дословно из stroy-bot-project-assigner. + +export const RESOURCES_TEXTS = { + noProject: 'Проект не найден. Откройте страницу через список проектов.', + loading: 'Загрузка...', + dismissError: 'Закрыть', + tabs: { + professions: (count: number): string => `Профессии (${count})`, + companies: (count: number): string => `Компании (${count})` + }, + pool: { + title: 'Доступные', + searchPlaceholder: 'Поиск...', + clearSearch: 'Очистить', + alreadyAdded: 'уже добавлено', + add: 'Добавить', + notFound: 'Ничего не найдено', + noItems: 'Нет элементов' + }, + assigned: { + title: 'Привязаны к проекту', + empty: 'Ничего не добавлено', + remove: 'Удалить' + }, + errors: { + load: 'Ошибка загрузки данных: ', + addProfession: 'Ошибка добавления профессии: ', + removeProfession: 'Ошибка удаления профессии: ', + addCompany: 'Ошибка добавления компании: ', + removeCompany: 'Ошибка удаления компании: ' + } +} as const; diff --git a/src/webparts/stroyBot/pages/resources/useAssignedActions.ts b/src/webparts/stroyBot/pages/resources/useAssignedActions.ts new file mode 100644 index 0000000..3c816c3 --- /dev/null +++ b/src/webparts/stroyBot/pages/resources/useAssignedActions.ts @@ -0,0 +1,66 @@ +import * as React from 'react'; +import { useAppServices } from '../../app/AppServicesContext'; +import { addAssignedResource, deleteAssignedResource } from './resourcesRepository'; +import { appendAssigned, createTempId, errorText, removeAssigned, replaceTempId } from './resourcesRules'; +import { IAssignedResource, IResourceItem, ResourceKind, ResourceRecord } from './resourcesTypes'; +import { RESOURCES_TEXTS } from './texts'; + +const ERROR_PREFIX: ResourceRecord<{ add: string; remove: string }> = { + professions: { add: RESOURCES_TEXTS.errors.addProfession, remove: RESOURCES_TEXTS.errors.removeProfession }, + companies: { add: RESOURCES_TEXTS.errors.addCompany, remove: RESOURCES_TEXTS.errors.removeCompany } +}; + +interface IAssignedActionsDeps { + projectId: string; + assigned: ResourceRecord; + setAssigned: React.Dispatch>>; + setError: (message: string) => void; +} + +export interface IAssignedActions { + add: (kind: ResourceKind, item: IResourceItem) => Promise; + remove: (kind: ResourceKind, itemId: number) => Promise; +} + +/** + * Оптимистичные добавление и удаление привязок — как в assigner: + * список меняется сразу, при ошибке откатывается и показывается сообщение. + */ +export function useAssignedActions({ + projectId, + assigned, + setAssigned, + setError +}: IAssignedActionsDeps): IAssignedActions { + const { sharePoint, settings } = useAppServices(); + + const update = (kind: ResourceKind, change: (list: IAssignedResource[]) => IAssignedResource[]): void => + setAssigned((prev) => ({ ...prev, [kind]: change(prev[kind]) })); + + const add = async (kind: ResourceKind, item: IResourceItem): Promise => { + const tempId = createTempId(); + update(kind, (list) => appendAssigned(list, { id: tempId, title: item.title, referenceId: item.id })); + try { + const realId = await addAssignedResource(sharePoint, settings.siteUrl, kind, projectId, item); + update(kind, (list) => replaceTempId(list, tempId, realId)); + } catch (error) { + update(kind, (list) => removeAssigned(list, tempId)); + setError(errorText(ERROR_PREFIX[kind].add, error)); + } + }; + + // Откат — к списку на момент клика (как в assigner). Удаление ещё не сохранённой записи + // (временный отрицательный id) уходит на сервер, падает и тоже откатывается — перенесено как есть. + const remove = async (kind: ResourceKind, itemId: number): Promise => { + const snapshot = assigned[kind]; + update(kind, (list) => removeAssigned(list, itemId)); + try { + await deleteAssignedResource(sharePoint, settings.siteUrl, kind, itemId); + } catch (error) { + update(kind, () => snapshot); + setError(errorText(ERROR_PREFIX[kind].remove, error)); + } + }; + + return { add, remove }; +} diff --git a/src/webparts/stroyBot/pages/resources/useProjectResources.ts b/src/webparts/stroyBot/pages/resources/useProjectResources.ts new file mode 100644 index 0000000..fe3de3e --- /dev/null +++ b/src/webparts/stroyBot/pages/resources/useProjectResources.ts @@ -0,0 +1,69 @@ +import * as React from 'react'; +import { useAppServices } from '../../app/AppServicesContext'; +import { fetchProjectResources } from './resourcesRepository'; +import { errorText } from './resourcesRules'; +import { IAssignedResource, IResourceItem, ResourceRecord } from './resourcesTypes'; +import { IAssignedActions, useAssignedActions } from './useAssignedActions'; +import { RESOURCES_TEXTS } from './texts'; + +const EMPTY_POOLS: ResourceRecord = { professions: [], companies: [] }; +const EMPTY_ASSIGNED: ResourceRecord = { professions: [], companies: [] }; + +export interface IProjectResourcesState extends IAssignedActions { + projectName: string | undefined; + pools: ResourceRecord; + assigned: ResourceRecord; + isLoading: boolean; + error: string | undefined; + clearError: () => void; +} + +/** + * Данные раздела. Ошибка загрузки не прячет страницу: как в assigner, показываются + * сообщение и пустые вкладки. Без projectId ничего не грузится. + */ +export function useProjectResources(projectId: string | undefined): IProjectResourcesState { + const { sharePoint, settings } = useAppServices(); + const [projectName, setProjectName] = React.useState(undefined); + const [pools, setPools] = React.useState>(EMPTY_POOLS); + const [assigned, setAssigned] = React.useState>(EMPTY_ASSIGNED); + const [isLoading, setIsLoading] = React.useState(!!projectId); + const [error, setError] = React.useState(undefined); + + React.useEffect(() => { + if (!projectId) { + setIsLoading(false); + return undefined; + } + let isCancelled = false; + setIsLoading(true); + fetchProjectResources(sharePoint, settings.siteUrl, projectId) + .then((loaded) => { + if (isCancelled) return; + setProjectName(loaded.projectName); + setPools(loaded.pools); + setAssigned(loaded.assigned); + setIsLoading(false); + }) + .catch((loadError: unknown) => { + if (isCancelled) return; + setError(errorText(RESOURCES_TEXTS.errors.load, loadError)); + setIsLoading(false); + }); + return () => { + isCancelled = true; + }; + }, [sharePoint, settings.siteUrl, projectId]); + + const actions = useAssignedActions({ projectId: projectId ?? '', assigned, setAssigned, setError }); + + return { + projectName, + pools, + assigned, + isLoading, + error, + clearError: () => setError(undefined), + ...actions + }; +}