Files
stroy-bot-monorepo/src/webparts/stroyBot/pages/resources/resourcesRepository.ts
T
Тимур Городецкий 55ce772eaa 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>
2026-10-16 08:46:08 +05:00

167 lines
6.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 })
);
}