55ce772eaa
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>
167 lines
6.2 KiB
TypeScript
167 lines
6.2 KiB
TypeScript
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 })
|
||
);
|
||
}
|