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:
@@ -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 })
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user