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) <noreply@anthropic.com>
This commit is contained in:
Тимур Городецкий
2026-10-15 17:18:19 +05:00
parent 11bf491095
commit 55ce772eaa
15 changed files with 1083 additions and 0 deletions
@@ -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<ResourceKind, IResourceListConfig> = {
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<string, string> = {
Accept: 'application/json;odata=nometadata',
'Content-Type': 'application/json;odata=nometadata',
'odata-version': ''
};
export const SP_DELETE_HEADERS: Record<string, string> = {
'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<T> {
value: T[];
'odata.nextLink'?: string;
'@odata.nextLink'?: string;
}
/** Все страницы коллекции: SharePoint отдаёт по 100 строк и ссылку на следующую страницу. */
export async function fetchAllItems<T>(client: IHttpClient, url: string): Promise<T[]> {
const items: T[] = [];
let nextUrl: string | undefined = url;
while (nextUrl) {
const page: ISpPage<T> = await getJson<ISpPage<T>>(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<IProjectResources> {
const [project, professions, companies, assignedProfessions, assignedCompanies] = await Promise.all([
getJson<{ Title: string }>(client, projectTitleUrl(siteUrl, projectId)),
fetchAllItems<ISpResourceRow>(client, poolItemsUrl(siteUrl, 'professions')),
fetchAllItems<ISpResourceRow>(client, poolItemsUrl(siteUrl, 'companies')),
fetchAllItems<ISpAssignedRow>(client, assignedItemsUrl(siteUrl, 'professions', projectId)),
fetchAllItems<ISpAssignedRow>(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<number> {
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<void> {
await ensureOk(
await client.fetch(assignedItemUrl(siteUrl, kind, itemId), { method: 'POST', headers: SP_DELETE_HEADERS })
);
}