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 }) ); }