Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz> Reviewed-on: http://git.tnsu.kz/timur.gorodetskiy/request-portal/pulls/9
This commit was merged in pull request #9.
This commit is contained in:
@@ -381,6 +381,7 @@ export class ApprovalListService {
|
||||
const batchedItems = batchedSP.web.lists.getByTitle(this.listTitle).items;
|
||||
rows.forEach((row) => {
|
||||
// void — по той же причине, что и в createSteps выше.
|
||||
// eslint-disable-next-line no-void -- без void это «висячий» промис, а no-floating-promises здесь уровня error
|
||||
void batchedItems.getById(row.id).delete();
|
||||
});
|
||||
await execute();
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http';
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { APPROVAL_API_CLIENT_ID, APPROVAL_API_BASE_URL } from '../config';
|
||||
import { ApprovalStatus, IApiResponse, IRequestType, IResolvedRoute, IResolveFieldValue } from './models/IApprovalRouteApi';
|
||||
import { APPROVAL_API_CLIENT_ID, getApprovalApiBaseUrl } from '../config';
|
||||
import {
|
||||
ApprovalStatus,
|
||||
ICurrentListPartition,
|
||||
IApiResponse,
|
||||
IRequestType,
|
||||
IRequestTypeListSeries,
|
||||
IResolvedRoute,
|
||||
IResolveFieldValue
|
||||
} from './models/IApprovalRouteApi';
|
||||
|
||||
// Все обращения к внешнему approval-route API — только здесь.
|
||||
// Клиент авторизуется через AadHttpClient (SSO по текущему пользователю).
|
||||
@@ -14,7 +22,7 @@ async function client(context: WebPartContext): Promise<AadHttpClient> {
|
||||
export async function getRequestTypes(context: WebPartContext): Promise<IRequestType[]> {
|
||||
const c = await client(context);
|
||||
const response: HttpClientResponse = await c.get(
|
||||
`${APPROVAL_API_BASE_URL}/api/request-types`,
|
||||
`${getApprovalApiBaseUrl()}/api/request-types`,
|
||||
AadHttpClient.configurations.v1
|
||||
);
|
||||
if (!response.ok) {
|
||||
@@ -24,6 +32,53 @@ export async function getRequestTypes(context: WebPartContext): Promise<IRequest
|
||||
return body.data ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/request-types/lists — серии партиций сразу по ВСЕМ типам заявок.
|
||||
*
|
||||
* Именно bulk-эндпоинт, а не по одному на тип: партиции нужны и для записи (куда
|
||||
* писать), и для поиска (по каким спискам искать), причём поиск идёт сразу по
|
||||
* всем типам — запрашивать серию отдельно на каждый тип означало бы N запросов
|
||||
* на каждое открытие «Моих заявок».
|
||||
*
|
||||
* Типы без настроенного списка SharePoint в ответ не попадают.
|
||||
*/
|
||||
export async function getListPartitions(context: WebPartContext): Promise<IRequestTypeListSeries[]> {
|
||||
const c = await client(context);
|
||||
const response: HttpClientResponse = await c.get(
|
||||
`${getApprovalApiBaseUrl()}/api/request-types/lists`,
|
||||
AadHttpClient.configurations.v1
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`request-types/lists: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
const body: IApiResponse<IRequestTypeListSeries[]> = await response.json();
|
||||
return body.data ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/request-types/{id}/lists/current — партиция, в которую нужно писать
|
||||
* НОВУЮ заявку этого типа. Вызывать перед КАЖДЫМ созданием, не кешировать: этот
|
||||
* запрос заодно проверяет порог элементов на бэкенде и ставит в очередь
|
||||
* создание следующей партиции при достижении — без него архивация никогда не
|
||||
* запустится, сколько бы заявок ни создали (bulk-эндпоинт lists, который
|
||||
* портал и так грузит при onInit, только читает реестр и порог не проверяет).
|
||||
*/
|
||||
export async function getCurrentListPartition(
|
||||
context: WebPartContext,
|
||||
requestTypeId: number
|
||||
): Promise<ICurrentListPartition> {
|
||||
const c = await client(context);
|
||||
const response: HttpClientResponse = await c.get(
|
||||
`${getApprovalApiBaseUrl()}/api/request-types/${requestTypeId}/lists/current`,
|
||||
AadHttpClient.configurations.v1
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`request-types/${requestTypeId}/lists/current: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
const body: IApiResponse<ICurrentListPartition> = await response.json();
|
||||
return body.data;
|
||||
}
|
||||
|
||||
// Кеш на уровне МОДУЛЯ (не useRef/useState) — список типов заявок админ меняет
|
||||
// редко, а без кеша каждая форма и каждый переход в режим согласующего дёргали
|
||||
// бы /api/request-types заново. Живёт до перезагрузки страницы — тот же принцип,
|
||||
@@ -77,12 +132,14 @@ export async function resolveRoute(
|
||||
requestTypeId: number,
|
||||
userEmail: string,
|
||||
status: ApprovalStatus = 'Approval',
|
||||
// eslint-disable-next-line @rushstack/no-new-null -- null отправляется в теле запроса как «не задано», undefined в JSON.stringify пропал бы вовсе
|
||||
departmentId: string | null = null,
|
||||
fieldValues: IResolveFieldValue[] = [],
|
||||
// eslint-disable-next-line @rushstack/no-new-null -- см. departmentId
|
||||
projectId: string | null = null
|
||||
): Promise<IResolvedRoute> {
|
||||
const c = await client(context);
|
||||
const url = `${APPROVAL_API_BASE_URL}/api/template-routes/resolve`;
|
||||
const url = `${getApprovalApiBaseUrl()}/api/template-routes/resolve`;
|
||||
|
||||
// Эндпоинт сменился с GET (query) на POST (JSON-тело).
|
||||
// projectId (uuid проекта) — для форм, где согласующий зависит от проекта
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { RequestTypeId } from '../../models/types';
|
||||
import { approvalListTitle, approvalListTitleForListId } from '../listTitles';
|
||||
import { ApprovalListService } from './approvalList.service';
|
||||
|
||||
/**
|
||||
* Экземпляр сервиса списка согласования формы.
|
||||
*
|
||||
* listId — партиция КОНКРЕТНОЙ заявки (см. shared/approval/listPartitions.ts).
|
||||
* Не задан — текущая партиция, как раньше. Список согласования ищется только по
|
||||
* имени (нет прямой ссылки на него, в отличие от списка данных), поэтому для
|
||||
* архивной партиции имя приходится резолвить через реестр — если реестр не
|
||||
* знает про такой listId (устаревшая ссылка), резолвер бросит понятную ошибку,
|
||||
* а не тихо откатится на текущую партицию.
|
||||
*
|
||||
* Раньше каждая форма объявляла свою «xApprovalService» с телом-копией этой
|
||||
* функции (8 одинаковых реализаций) — оставлены только тонкие обёртки под
|
||||
* именем формы (см. submit.ts каждой формы), чтобы вызывающий код не терял
|
||||
* читаемость («vacationApprovalService» вместо безликого параметра formId).
|
||||
*/
|
||||
export function approvalServiceFor(formId: RequestTypeId, listId?: string): ApprovalListService {
|
||||
return new ApprovalListService(
|
||||
listId ? approvalListTitleForListId(formId, listId) : approvalListTitle(formId)
|
||||
);
|
||||
}
|
||||
@@ -15,8 +15,10 @@ export async function getPlannedMatrix(
|
||||
userEmail: string,
|
||||
approval: ApprovalListService,
|
||||
status: ApprovalStatus = 'Approval',
|
||||
// eslint-disable-next-line @rushstack/no-new-null -- транзитом в resolveRoute(), см. её departmentId
|
||||
departmentId?: string | null,
|
||||
fieldValues?: IResolveFieldValue[],
|
||||
// eslint-disable-next-line @rushstack/no-new-null -- см. departmentId
|
||||
projectId?: string | null
|
||||
): Promise<IMatrixRow[]> {
|
||||
if (!userEmail) return [];
|
||||
@@ -36,6 +38,7 @@ export async function getPlannedMatrix(
|
||||
phase,
|
||||
slaHours: s.slaHours,
|
||||
askOverdueReason: s.askOverdueReason,
|
||||
warning: s.warning,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getSP } from '../pnp/pnpjs';
|
||||
import '@pnp/sp/webs';
|
||||
import '@pnp/sp/lists';
|
||||
import { IList } from '@pnp/sp/lists';
|
||||
import '@pnp/sp/items';
|
||||
import { ApprovalListService } from './approvalList.service';
|
||||
import { REQUEST_STATUS, APPROVAL_PHASE } from './models/statuses';
|
||||
@@ -33,9 +34,17 @@ const ROW_STATUS: Record<ApproverDecision, string> = {
|
||||
// заявке (requestId). Пока в портале нет маршрутизации на форму заявки, этот сервис
|
||||
// готов, но UI-режим согласующего ещё не подключён (см. план).
|
||||
export class ApproverService {
|
||||
/**
|
||||
* dataListRef — либо Title списка данных (текущая партиция, как раньше), либо
|
||||
* его серверный относительный url (конкретная, в т.ч. архивная партиция —
|
||||
* см. shared/listTitles.ts). Различаем по ведущему '/': Title списка в
|
||||
* SharePoint с него не начинается, url — всегда начинается. Так конструктору
|
||||
* не нужен третий параметр «что именно передали» — вызывающая сторона просто
|
||||
* передаёт то, что у неё есть (см. submit.ts каждой формы).
|
||||
*/
|
||||
constructor(
|
||||
private readonly approval: ApprovalListService,
|
||||
private readonly dataListTitle: string
|
||||
private readonly dataListRef: string
|
||||
) {}
|
||||
|
||||
// Ожидающая строка текущего пользователя, если сейчас его очередь; иначе undefined.
|
||||
@@ -162,8 +171,14 @@ export class ApproverService {
|
||||
return REQUEST_STATUS.execution;
|
||||
}
|
||||
|
||||
private dataList(): IList {
|
||||
return this.dataListRef.startsWith('/')
|
||||
? getSP().web.getList(this.dataListRef)
|
||||
: getSP().web.lists.getByTitle(this.dataListRef);
|
||||
}
|
||||
|
||||
private async updateRequestStatus(id: number, status: string): Promise<void> {
|
||||
await getSP().web.lists.getByTitle(this.dataListTitle).items.getById(id).update({
|
||||
await this.dataList().items.getById(id).update({
|
||||
RequestStatus: status,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -13,23 +13,44 @@ export type { IMatrixRow } from './models/IMatrixRow';
|
||||
export type { IApprovalRow } from './models/IApprovalRow';
|
||||
export type {
|
||||
ApprovalStatus,
|
||||
IListPartition,
|
||||
ICurrentListPartition,
|
||||
IRequestType,
|
||||
IRequestTypeListSeries,
|
||||
IResolvedRoute,
|
||||
IResolvedRouteStep,
|
||||
IResolveFieldValue,
|
||||
} from './models/IApprovalRouteApi';
|
||||
|
||||
export { getRequestTypes, getRequestTypeIdByPageUrl, resolveRoute } from './approvalRoute.service';
|
||||
export {
|
||||
getRequestTypes,
|
||||
getRequestTypeIdByPageUrl,
|
||||
getListPartitions,
|
||||
getCurrentListPartition,
|
||||
resolveRoute
|
||||
} from './approvalRoute.service';
|
||||
export {
|
||||
initListPartitions,
|
||||
listPartitionsLoaded,
|
||||
partitionsForTypeId,
|
||||
currentPartitionForTypeId,
|
||||
currentPartitionForPageUrl,
|
||||
partitionForPageUrlAndListId,
|
||||
isConfirmedStaleListId,
|
||||
registerCurrentPartition,
|
||||
} from './listPartitions';
|
||||
export { resolveWriteListId } from './writeListId';
|
||||
export { useRequestTypeId } from './useRequestTypeId';
|
||||
export type { IRequestTypeIdState } from './useRequestTypeId';
|
||||
export { ApprovalListService } from './approvalList.service';
|
||||
export type { IApproverToWrite } from './approvalList.service';
|
||||
export { approvalServiceFor } from './approvalServiceFactory';
|
||||
export { ApproverService } from './approverDecision.service';
|
||||
export type { ApproverDecision, IPendingRow } from './approverDecision.service';
|
||||
export { getPlannedMatrix, writeApprovers, writeRoute } from './approvalSubmission.service';
|
||||
export { notifyActiveStep } from './notify.service';
|
||||
export type { INotifyTarget } from './notify.service';
|
||||
export { useMatrixEditor, findUnassignedStep } from './useMatrixEditor';
|
||||
export { useMatrixEditor, findUnassignedStep, unassignedStepMessage } from './useMatrixEditor';
|
||||
export type { IUseMatrixEditor } from './useMatrixEditor';
|
||||
export { ApprovalMatrix } from './ui/ApprovalMatrix';
|
||||
export { UserPicker } from './ui/UserPicker';
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { getListPartitions, getRequestTypes } from './approvalRoute.service';
|
||||
import { ICurrentListPartition, IListPartition } from './models/IApprovalRouteApi';
|
||||
|
||||
/**
|
||||
* Реестр партиций списков — единственное место, которое знает, куда ПИСАТЬ и где
|
||||
* ИСКАТЬ заявки каждого типа.
|
||||
*
|
||||
* Заполняется один раз при старте web part (initListPartitions в onInit), дальше
|
||||
* читается синхронно. Синхронные аксессоры принципиальны: имена списков нужны в
|
||||
* пропсах JSX и в useMemo форм (см. shared/listTitles.ts), а формы — это лениво
|
||||
* подгружаемые чанки, которые монтируются заведомо позже onInit. Тот же приём,
|
||||
* что у initSP/getSP и setImpersonatedEmail.
|
||||
*
|
||||
* Если approval-route недоступен, реестр остаётся пустым, и вызывающая сторона
|
||||
* молча откатывается к прежнему поведению «одна партиция по RequestType.listId» —
|
||||
* портал обязан принимать заявки даже когда админский API лежит.
|
||||
*/
|
||||
|
||||
/** Партиции по ЧИСЛОВОМУ id типа заявки. */
|
||||
const partitionsByTypeId = new Map<number, IListPartition[]>();
|
||||
|
||||
/** pageUrl (слаг формы, напр. 'hr-vacation') → числовой id типа заявки. */
|
||||
const typeIdByPageUrl = new Map<string, number>();
|
||||
|
||||
let loaded = false;
|
||||
|
||||
/**
|
||||
* Загрузить серии партиций и карту слагов. Вызывать один раз в onInit.
|
||||
*
|
||||
* Ошибку НЕ пробрасываем: портал должен подняться и работать по прежней
|
||||
* конвенции имён, даже если approval-route не ответил. Иначе недоступность
|
||||
* админского API означала бы полностью нерабочий портал.
|
||||
*/
|
||||
export async function initListPartitions(context: WebPartContext): Promise<void> {
|
||||
try {
|
||||
// Серии — по числовому id, а имена списков в формах — по слагу (pageUrl),
|
||||
// поэтому нужен и справочник типов, чтобы связать одно с другим.
|
||||
const [series, types] = await Promise.all([getListPartitions(context), getRequestTypes(context)]);
|
||||
|
||||
partitionsByTypeId.clear();
|
||||
typeIdByPageUrl.clear();
|
||||
|
||||
types.forEach((t) => {
|
||||
if (t.pageUrl) typeIdByPageUrl.set(t.pageUrl, t.id);
|
||||
});
|
||||
|
||||
series.forEach((s) => {
|
||||
// По номеру, а не в порядке ответа: номер задаёт хронологию серии, и на него
|
||||
// же опирается выбор «последней» партиции ниже.
|
||||
const sorted = [...s.partitions].sort((a, b) => a.number - b.number);
|
||||
partitionsByTypeId.set(s.requestTypeId, sorted);
|
||||
});
|
||||
|
||||
loaded = true;
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
'[Партиции списков] не удалось загрузить серии из approval-route — работаем по конвенции имён с партицией №1',
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Загрузился ли реестр. false — все аксессоры ниже отдают пустоту, и вызывающий использует свой фолбэк. */
|
||||
export function listPartitionsLoaded(): boolean {
|
||||
return loaded;
|
||||
}
|
||||
|
||||
/** Все партиции типа заявки (архив + текущая), по возрастанию номера. Пусто — реестр не загружен или тип без списка. */
|
||||
export function partitionsForTypeId(requestTypeId: number): IListPartition[] {
|
||||
return partitionsByTypeId.get(requestTypeId) ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Партиция, в которую идёт запись новых заявок.
|
||||
*
|
||||
* Если сервер по какой-то причине не отметил ни одну как текущую, берём с
|
||||
* наибольшим номером — это та же партиция по смыслу, и лучше записать в неё, чем
|
||||
* отказать пользователю в создании заявки.
|
||||
*/
|
||||
export function currentPartitionForTypeId(requestTypeId: number): IListPartition | undefined {
|
||||
const partitions = partitionsForTypeId(requestTypeId);
|
||||
if (partitions.length === 0) return undefined;
|
||||
return partitions.filter((p) => p.isCurrent)[0] ?? partitions[partitions.length - 1];
|
||||
}
|
||||
|
||||
/** То же, но по слагу формы (pageUrl) — в формах известен именно он, а не числовой id. */
|
||||
export function currentPartitionForPageUrl(pageUrl: string): IListPartition | undefined {
|
||||
const id = typeIdByPageUrl.get(pageUrl);
|
||||
return id === undefined ? undefined : currentPartitionForTypeId(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Вписывает в кеш партицию, которую approval-route ТОЛЬКО ЧТО вернул как текущую
|
||||
* (см. getCurrentListPartition) — вызывать сразу после успешного ответа, ДО того
|
||||
* как эта партиция понадобится дальше в том же сохранении (approvalListTitleForListId
|
||||
* и т.п.), а не только при следующей перезагрузке страницы.
|
||||
*
|
||||
* Без этого партиция, созданная провижинингом ПОСЛЕ onInit (порог элементов
|
||||
* достигнут прямо во время этого запроса), была бы неизвестна isConfirmedStaleListId,
|
||||
* и попытка сохранить в неё же заявку — записав данные в новый список — падала бы
|
||||
* следующим шагом с «список не найден в реестре партиций», хотя ссылка совершенно
|
||||
* свежая и рабочая (см. форму пользователя: данные создались, ошибка была только
|
||||
* в определении имени списка согласования).
|
||||
*
|
||||
* Тип, о котором реестр вообще ничего не знает (initListPartitions не ответил при
|
||||
* старте — typeIdByPageUrl пуст для этого pageUrl), не трогаем: мержить партицию в
|
||||
* несуществующую серию нечем, а откат на конвенцию имён и так работает без реестра.
|
||||
*/
|
||||
export function registerCurrentPartition(pageUrl: string, partition: ICurrentListPartition): void {
|
||||
const id = typeIdByPageUrl.get(pageUrl);
|
||||
if (id === undefined) return;
|
||||
|
||||
const lower = partition.listId.toLowerCase();
|
||||
const merged: IListPartition[] = partitionsForTypeId(id)
|
||||
.filter((p) => p.listId.toLowerCase() !== lower)
|
||||
.map((p) => ({ ...p, isCurrent: false }));
|
||||
|
||||
merged.push({
|
||||
number: partition.number,
|
||||
siteUrl: partition.siteUrl,
|
||||
listId: partition.listId,
|
||||
title: partition.title,
|
||||
isCurrent: true,
|
||||
createdAt: new Date().toISOString()
|
||||
});
|
||||
merged.sort((a, b) => a.number - b.number);
|
||||
|
||||
partitionsByTypeId.set(id, merged);
|
||||
}
|
||||
|
||||
/**
|
||||
* Конкретная партиция по её listId — нужна, чтобы открыть/сохранить АРХИВНУЮ
|
||||
* заявку: тогда известен не номер, а сам listId (пришёл из строки поиска, см.
|
||||
* IMyRequest.listId в features/myRequests), и его надо сопоставить с записью в
|
||||
* реестре, чтобы узнать имя списка (для парного списка согласования — см.
|
||||
* shared/listTitles.ts, approvalListTitleForListId).
|
||||
*
|
||||
* undefined — либо реестр про тип вообще ничего не знает (серия пуста), либо
|
||||
* partitions есть, но именно такого listId среди них нет. Эти два случая —
|
||||
* РАЗНЫЕ по смыслу: см. isConfirmedStaleListId ниже, только он умеет их различать.
|
||||
*/
|
||||
export function partitionForPageUrlAndListId(pageUrl: string, listId: string): IListPartition | undefined {
|
||||
const id = typeIdByPageUrl.get(pageUrl);
|
||||
if (id === undefined) return undefined;
|
||||
const lower = listId.toLowerCase();
|
||||
return partitionsForTypeId(id).filter((p) => p.listId.toLowerCase() === lower)[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* true — ТОЛЬКО когда реестр про серию этого типа реально что-то знает (она не
|
||||
* пуста) и listId в неё не входит. Тогда ссылка гарантированно битая/устаревшая
|
||||
* (например, партицию удалили из реестра руками) — вот здесь уместна ошибка.
|
||||
*
|
||||
* Если же серия пуста — approval-route не ответил при старте, тип только что
|
||||
* появился, реестр ещё не успел зарегистрировать эту серию — мы просто НЕ ЗНАЕМ,
|
||||
* валиден ли listId, и вины на нём нет. В этом случае false: тот же приём
|
||||
* «доверять RequestType.listId без реестра», что и у listTargetsForType в
|
||||
* myRequests.service.ts — иначе временная недоступность approval-route выглядела
|
||||
* бы как «эта заявка вообще не открывается», хотя ссылка совершенно рабочая.
|
||||
*/
|
||||
export function isConfirmedStaleListId(pageUrl: string, listId: string): boolean {
|
||||
const id = typeIdByPageUrl.get(pageUrl);
|
||||
if (id === undefined) return false;
|
||||
const series = partitionsForTypeId(id);
|
||||
if (series.length === 0) return false;
|
||||
const lower = listId.toLowerCase();
|
||||
return !series.some((p) => p.listId.toLowerCase() === lower);
|
||||
}
|
||||
@@ -20,6 +20,53 @@ export interface IApiResponse<T> {
|
||||
data: T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Одна партиция серии списков типа заявок.
|
||||
*
|
||||
* Списки заявок нумерованные: когда в текущем наборе элементов достигается
|
||||
* порог, approval-route создаёт следующую пару списков («<Форма> <N> — данные» +
|
||||
* «<Форма> <N> — согласование»), а прежняя пара остаётся архивом только для
|
||||
* чтения. Писать нужно в партицию с isCurrent, а ИСКАТЬ — по всем сразу,
|
||||
* иначе заявки из архива просто исчезнут из выдачи.
|
||||
*/
|
||||
export interface IListPartition {
|
||||
/** Номер партиции в серии, с 1. */
|
||||
number: number;
|
||||
/** Абсолютный URL сайта, где живёт список этой партиции. */
|
||||
siteUrl: string;
|
||||
/** Серверный относительный URL списка данных — та же семантика, что у IRequestType.listId. */
|
||||
listId: string;
|
||||
/** Title списка данных на момент создания. null, если approval-route не смог его прочитать. */
|
||||
title?: string;
|
||||
/** true — в эту партицию идёт запись новых заявок. Ровно одна в серии. */
|
||||
isCurrent: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// GET /api/request-types/lists → { data: IRequestTypeListSeries[] }
|
||||
// Серии сразу по всем типам заявок: одним запросом, а не по одному на тип.
|
||||
export interface IRequestTypeListSeries {
|
||||
requestTypeId: number;
|
||||
partitions: IListPartition[];
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/request-types/{id}/lists/current → { data: ICurrentListPartition }
|
||||
*
|
||||
* Партиция, в которую нужно писать НОВУЮ заявку. Не чистый GET на бэкенде —
|
||||
* этот же запрос проверяет порог элементов и при необходимости ставит в
|
||||
* очередь создание следующей партиции, поэтому его нужно звать перед КАЖДЫМ
|
||||
* созданием заявки, а не читать из кеша (см. shared/approval/listPartitions.ts,
|
||||
* который для этого не подходит — грузится один раз при onInit).
|
||||
*/
|
||||
export interface ICurrentListPartition {
|
||||
requestTypeId: number;
|
||||
number: number;
|
||||
siteUrl: string;
|
||||
listId: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
// Условие видимости шага маршрута (настраивается в админке маршрутов —
|
||||
// «Показывать шаг только при выполнении условия», сравнение «Поле списка»/
|
||||
// «Оператор»/«Значение»). fieldName — внутреннее имя колонки списка (напр.
|
||||
@@ -41,11 +88,14 @@ export interface IResolvedRoute {
|
||||
export interface IResolvedRouteStep {
|
||||
id: number;
|
||||
orderNo: number;
|
||||
// eslint-disable-next-line @rushstack/no-new-null -- зеркалит nullable-контракт бэкенда
|
||||
executorEmail: string | null;
|
||||
// eslint-disable-next-line @rushstack/no-new-null -- см. executorEmail
|
||||
position: string | null;
|
||||
allowReassignToOtherApprovers: boolean;
|
||||
// Предупреждение маршрута по этому шагу (например, роль не разрешилась в
|
||||
// конкретного человека) — приходит от API, показываем автору при желании.
|
||||
// eslint-disable-next-line @rushstack/no-new-null -- см. executorEmail
|
||||
warning: string | null;
|
||||
// SLA шага в часах и признак опроса причины просрочки. Наследование
|
||||
// «шаг перекрывает тип заявки» бэкенд уже применил — здесь итоговое значение,
|
||||
|
||||
@@ -31,4 +31,16 @@ export interface IMatrixRow {
|
||||
slaHours?: number | null;
|
||||
// eslint-disable-next-line @rushstack/no-new-null -- см. slaHours
|
||||
askOverdueReason?: boolean | null;
|
||||
/**
|
||||
* Предупреждение approval-route по ЭТОМУ шагу (см. IResolvedRouteStep.warning) —
|
||||
* например, роль не разрешилась в конкретного человека, и почему именно.
|
||||
* Только у ПЛАНИРУЕМОГО маршрута (см. getPlannedMatrix): у уже записанных строк
|
||||
* (loadXxxRequest) неоткуда взяться — резолв запускался один раз при подаче.
|
||||
*
|
||||
* findUnassignedStep показывает его вместо своего общего текста, когда он есть —
|
||||
* бэкенд знает причину точнее («роль не назначена в справочнике отдела» и т.п.),
|
||||
* а не просто «нет назначенного человека».
|
||||
*/
|
||||
// eslint-disable-next-line @rushstack/no-new-null -- зеркалит nullable-контракт бэкенда (IResolvedRouteStep.warning)
|
||||
warning?: string | null;
|
||||
}
|
||||
|
||||
@@ -144,6 +144,17 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
||||
)}
|
||||
<td data-label={roleLabel}>
|
||||
{r.who}
|
||||
{/* Причина, по которой approval-route не назначил на этот шаг
|
||||
конкретного человека (роль не разрешилась и т.п., см.
|
||||
IResolvedRouteStep.warning/IMatrixRow.warning) — та же строка,
|
||||
что уходит в ошибку валидации при отправке (см.
|
||||
unassignedStepMessage), но видна СРАЗУ в таблице, а не только
|
||||
на попытке отправить: пустая строка «Согласующий» сама по себе
|
||||
не объясняет, что делать — вручную выбрать человека здесь
|
||||
нельзя, шаг заведомо требует правки маршрута в админке. */}
|
||||
{!r.who && r.warning && (
|
||||
<span className={styles.warningInline}>{r.warning}</span>
|
||||
)}
|
||||
{/* Доп. согласующий, добавленный другим согласующим (с правом
|
||||
переназначения) — помечаем, кто его добавил. Пусто у обычных
|
||||
строк маршрута. Как и комментарий — inline под именем, чтобы
|
||||
|
||||
@@ -169,6 +169,19 @@
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
// Причина, по которой approval-route не назначил человека на шаг (см.
|
||||
// ApprovalMatrix.tsx/r.warning) — красным, как настоящая проблема маршрута
|
||||
// (в отличие от нейтрального addedByInline/commentInline): пустая строка
|
||||
// «Согласующий» без этого текста выглядит как баг, а не как «маршрут просит
|
||||
// вмешательства администратора».
|
||||
.warningInline {
|
||||
display: block;
|
||||
margin-top: 3px;
|
||||
font-size: $fs-caption;
|
||||
color: $red-500;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
// ВАЖНО: не display:flex — это сняло бы с ячейки display:table-cell, и она
|
||||
// перестала бы вставать в строку рядом с соседней (кнопки уезжали на вторую
|
||||
// строку). Ячейка остаётся табличной, кнопки держатся в строке через nowrap.
|
||||
|
||||
@@ -118,6 +118,25 @@ export function findUnassignedStep(rows: IMatrixRow[]): IMatrixRow | undefined {
|
||||
return rows.filter((r) => !r.email).sort((a, b) => a.order - b.order)[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Текст ошибки под первый незанятый шаг — то, что раньше было скопировано в
|
||||
* validateSubmit каждой формы (findUnassignedStep + `row.warning || <шаблон>`).
|
||||
* fallback принимает саму строку и решает, что писать, если approval-route не
|
||||
* прислал свою причину (warning) — у большинства форм это общий шаблон «без
|
||||
* назначенного человека — выберите …», но у некоторых (см. Ticket1CForm) текст
|
||||
* другой, поэтому шаблон не зашит сюда, а передаётся вызывающей стороной.
|
||||
*
|
||||
* undefined — назначенных не хватает не для этих rows (или все шаги заняты).
|
||||
*/
|
||||
export function unassignedStepMessage(
|
||||
rows: IMatrixRow[],
|
||||
fallback: (row: IMatrixRow) => string
|
||||
): string | undefined {
|
||||
const row = findUnassignedStep(rows);
|
||||
if (!row) return undefined;
|
||||
return row.warning || fallback(row);
|
||||
}
|
||||
|
||||
/**
|
||||
* Загружает матрицу согласования и (если editable) позволяет её редактировать.
|
||||
* @param load загрузчик строк (планируемый маршрут из resolve или строки заявки).
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { RequestTypeId } from '../../models/types';
|
||||
import { getCurrentListPartition } from './approvalRoute.service';
|
||||
import { registerCurrentPartition } from './listPartitions';
|
||||
|
||||
/**
|
||||
* Партиция, в которую фактически писать при сохранении заявки.
|
||||
*
|
||||
* existingId задан → редактируем существующую заявку, пишем в ту же партицию
|
||||
* (editListId), запись новых партиций тут ни при чём.
|
||||
*
|
||||
* existingId не задан → создаём новую заявку, и партицию для записи спрашиваем
|
||||
* у approval-route ПРЯМО СЕЙЧАС (а не берём из кеша onInit) — именно этот вызов
|
||||
* проверяет порог элементов текущего списка и ставит в очередь создание
|
||||
* следующей партиции при достижении; без него архивация никогда не сработает,
|
||||
* сколько бы заявок ни создали.
|
||||
*
|
||||
* Свежерезолвленная партиция сразу вписывается в локальный кеш
|
||||
* (registerCurrentPartition): если её создало провижинирование ПРЯМО ЭТИМ
|
||||
* запросом (порог как раз достигнут), кеш из onInit о ней ещё не знает, и без
|
||||
* этого следующий же шаг сохранения (обычно резолв имени списка согласования
|
||||
* через approvalListTitleForListId) упал бы с «список не найден в реестре
|
||||
* партиций», хотя данные уже успешно записались и ссылка совершенно рабочая —
|
||||
* ровно так это и проявилось на форме «Обходной лист».
|
||||
*
|
||||
* Сбой запроса к approval-route не должен блокировать подачу заявки — откат на
|
||||
* editListId (для новой заявки это undefined → список по конвенции имён,
|
||||
* прежнее поведение до появления партиционирования).
|
||||
*
|
||||
* Раньше эта логика (try/catch + getCurrentListPartition + registerCurrentPartition)
|
||||
* была скопирована в save() каждой из 8 форм — при первом же баге в ней пришлось
|
||||
* чинить один и тот же код в 8 файлах. Вынесена сюда, чтобы чинить один раз.
|
||||
*/
|
||||
export async function resolveWriteListId(
|
||||
context: WebPartContext,
|
||||
formId: RequestTypeId,
|
||||
requestTypeId: number,
|
||||
existingId: number | undefined,
|
||||
editListId: string | undefined
|
||||
): Promise<string | undefined> {
|
||||
if (existingId) return editListId;
|
||||
|
||||
try {
|
||||
const partition = await getCurrentListPartition(context, requestTypeId);
|
||||
registerCurrentPartition(formId, partition);
|
||||
return partition.listId;
|
||||
} catch (e) {
|
||||
console.error('Не удалось получить текущую партицию списка — используется список по конвенции имён', e);
|
||||
return editListId;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
import { getSP } from '../pnp/pnpjs';
|
||||
import '@pnp/sp/lists';
|
||||
import { IList } from '@pnp/sp/lists';
|
||||
import '@pnp/sp/items';
|
||||
import '@pnp/sp/attachments';
|
||||
|
||||
export interface IAttachmentInfo {
|
||||
@@ -7,6 +10,18 @@ export interface IAttachmentInfo {
|
||||
url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* listRef — либо Title списка данных (текущая партиция), либо его серверный
|
||||
* относительный url (конкретная, в т.ч. архивная партиция — см. shared/
|
||||
* listTitles.ts). Различаем по ведущему '/', тем же приёмом, что и
|
||||
* ApproverService.dataListRef — иначе вложения заявки, лежащей в архивной
|
||||
* партиции, читались/писались бы в список ТЕКУЩЕЙ (там либо нет элемента с
|
||||
* таким Id, либо, хуже, есть чужой элемент с тем же Id).
|
||||
*/
|
||||
function resolveList(listRef: string): IList {
|
||||
return listRef.startsWith('/') ? getSP().web.getList(listRef) : getSP().web.lists.getByTitle(listRef);
|
||||
}
|
||||
|
||||
/** Прочитать файл как ArrayBuffer (FileReader — совместимо со всеми браузерами). */
|
||||
function readAsArrayBuffer(file: File): Promise<ArrayBuffer> {
|
||||
return new Promise<ArrayBuffer>((resolve, reject) => {
|
||||
@@ -18,11 +33,8 @@ function readAsArrayBuffer(file: File): Promise<ArrayBuffer> {
|
||||
}
|
||||
|
||||
/** Вложения элемента списка (для режима согласующего — ссылки на скачивание). */
|
||||
export async function getAttachments(listTitle: string, itemId: number): Promise<IAttachmentInfo[]> {
|
||||
const files = await getSP()
|
||||
.web.lists.getByTitle(listTitle)
|
||||
.items.getById(itemId)
|
||||
.attachmentFiles();
|
||||
export async function getAttachments(listRef: string, itemId: number): Promise<IAttachmentInfo[]> {
|
||||
const files = await resolveList(listRef).items.getById(itemId).attachmentFiles();
|
||||
return files.map((f) => ({ fileName: f.FileName, url: f.ServerRelativeUrl }));
|
||||
}
|
||||
|
||||
@@ -31,14 +43,14 @@ export async function getAttachments(listTitle: string, itemId: number): Promise
|
||||
* Вызывать ПОСЛЕ создания элемента (нужен его id).
|
||||
*/
|
||||
export async function uploadAttachments(
|
||||
listTitle: string,
|
||||
listRef: string,
|
||||
itemId: number,
|
||||
files: File[]
|
||||
): Promise<void> {
|
||||
if (files.length === 0) {
|
||||
return;
|
||||
}
|
||||
const item = getSP().web.lists.getByTitle(listTitle).items.getById(itemId);
|
||||
const item = resolveList(listRef).items.getById(itemId);
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
const content = await readAsArrayBuffer(file);
|
||||
@@ -48,13 +60,9 @@ export async function uploadAttachments(
|
||||
|
||||
/** Удалить вложение элемента по имени файла (для правки заявки). */
|
||||
export async function deleteAttachment(
|
||||
listTitle: string,
|
||||
listRef: string,
|
||||
itemId: number,
|
||||
fileName: string
|
||||
): Promise<void> {
|
||||
await getSP()
|
||||
.web.lists.getByTitle(listTitle)
|
||||
.items.getById(itemId)
|
||||
.attachmentFiles.getByName(fileName)
|
||||
.delete();
|
||||
await resolveList(listRef).items.getById(itemId).attachmentFiles.getByName(fileName).delete();
|
||||
}
|
||||
|
||||
@@ -16,7 +16,27 @@ export const DATA_SITE_URL = 'https://tnsukz.sharepoint.com/sites/requests';
|
||||
* в Центре администрирования SharePoint (API access) при развёртывании.
|
||||
*/
|
||||
export const APPROVAL_API_CLIENT_ID = '9232d9e2-9807-4753-808f-07250e5474b0';
|
||||
export const APPROVAL_API_BASE_URL = 'https://api.tnsu.kz/approval-route';
|
||||
|
||||
/** Прод — то же самое значение, что default в свойстве approvalApiBaseUrl веб-части. */
|
||||
export const APPROVAL_API_BASE_URL_PROD = 'https://api.tnsu.kz/approval-route';
|
||||
/** Локальный backend — порт из tnsu-approval-route/backend/.../launchSettings.json. */
|
||||
export const APPROVAL_API_BASE_URL_LOCAL = 'http://localhost:5080';
|
||||
|
||||
// Настраивается свойством веб-части approvalApiBaseUrl (селект в панели свойств,
|
||||
// см. RequestPortalWebPart.render) — тот же приём, что и у debugUserQuery/
|
||||
// setImpersonatedIdentity (shared/impersonation.ts): модульный синглтон, а не
|
||||
// проброс параметром через все вызовы approvalRoute.service.ts, потому что эти
|
||||
// вызовы разбросаны по формам и не имеют общего входа, через который можно
|
||||
// было бы прокинуть контекст.
|
||||
let _approvalApiBaseUrl = APPROVAL_API_BASE_URL_PROD;
|
||||
|
||||
export function setApprovalApiBaseUrl(url: string): void {
|
||||
_approvalApiBaseUrl = url || APPROVAL_API_BASE_URL_PROD;
|
||||
}
|
||||
|
||||
export function getApprovalApiBaseUrl(): string {
|
||||
return _approvalApiBaseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Справочники (Dictionary API) — тот же gateway api.tnsu.kz и тот же AAD-app
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import '@pnp/sp/webs';
|
||||
import '@pnp/sp/lists';
|
||||
import '@pnp/sp/items';
|
||||
import { IList } from '@pnp/sp/lists';
|
||||
import { getSP } from './pnp/pnpjs';
|
||||
import { buildRequestUniqueCode } from './requestCode';
|
||||
import { dataListTitle } from './listTitles';
|
||||
import { RequestTypeId } from '../models/types';
|
||||
|
||||
/**
|
||||
* Список данных, в который фактически пишем: listId (конкретная партиция —
|
||||
* либо ОБНОВЛЯЕМОЙ заявки, либо только что резолвленная текущая, см.
|
||||
* getCurrentListPartition) или, если он не задан, список по конвенции имён
|
||||
* (см. dataListTitle) — тот же откат, что действовал до партиционирования.
|
||||
*
|
||||
* Раньше эта строка (`listId ? getSP().web.getList(listId) : ...`) была
|
||||
* скопирована в save/update-функции каждой формы и в submit.ts отдельных
|
||||
* форм (Ticket1C — Planner, Clearance — updateApproverFields) — 8+ мест с
|
||||
* одинаковым кодом. Расхождение здесь означало бы, что часть операций тихо
|
||||
* пишет не в ту партицию.
|
||||
*/
|
||||
export function resolveDataList(formId: RequestTypeId, listId?: string): IList {
|
||||
return listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(formId));
|
||||
}
|
||||
|
||||
export interface ISaveItemResult {
|
||||
id: number;
|
||||
/** Задан только при создании новой заявки (существующий код не меняется). */
|
||||
code?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Общее ядро сохранения элемента списка данных: резолв списка, создание/обновление,
|
||||
* генерация уникального кода заявки. Вынесено из saveXRequest каждой формы — сам
|
||||
* набор полей (buildItem) и работа с маршрутом (writeRoute) остаются у формы,
|
||||
* форма отличается только ими.
|
||||
*
|
||||
* existingId задан → обновляем ту же запись, иначе создаём новую.
|
||||
* codePrefix — обычно числовой RequestTypeId, но может быть строкой-меткой
|
||||
* (см. buildRequestUniqueCode, TICKET1C_CODE_PREFIX).
|
||||
*/
|
||||
export async function saveRequestItem(
|
||||
formId: RequestTypeId,
|
||||
existingId: number | undefined,
|
||||
listId: string | undefined,
|
||||
codePrefix: number | string,
|
||||
item: Record<string, unknown>
|
||||
): Promise<ISaveItemResult> {
|
||||
const list = resolveDataList(formId, listId);
|
||||
|
||||
if (existingId) {
|
||||
await list.items.getById(existingId).update(item);
|
||||
return { id: existingId };
|
||||
}
|
||||
|
||||
const added = await list.items.add(item);
|
||||
const id = (added as { Id: number }).Id;
|
||||
// Код известен только после создания элемента (нужен его Id).
|
||||
const code = buildRequestUniqueCode(codePrefix, id, new Date());
|
||||
await list.items.getById(id).update({ RequestUniqueCode: code });
|
||||
return { id, code };
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import '@pnp/sp/webs';
|
||||
import '@pnp/sp/site-users/web';
|
||||
import { getSP } from './pnp/pnpjs';
|
||||
|
||||
/**
|
||||
* email / claims-логин → пользователь сайта (Id для Person-поля + Title для заголовка).
|
||||
* Было скопировано в submit.ts нескольких форм с сотрудником-инициатором
|
||||
* (Vacation, Clearance) — вынесено сюда, чтобы не разойтись при правке.
|
||||
*/
|
||||
export async function ensureEmployee(login: string): Promise<{ Id: number; Title: string }> {
|
||||
const full = login.indexOf('|') >= 0 ? login : `i:0#.f|membership|${login}`;
|
||||
return getSP().web.ensureUser(full);
|
||||
}
|
||||
@@ -1,4 +1,9 @@
|
||||
import { RequestTypeId } from '../models/types';
|
||||
import {
|
||||
currentPartitionForPageUrl,
|
||||
isConfirmedStaleListId,
|
||||
partitionForPageUrlAndListId
|
||||
} from './approval/listPartitions';
|
||||
|
||||
/**
|
||||
* ЕДИНЫЙ источник имён списков SharePoint.
|
||||
@@ -32,15 +37,19 @@ const LIST_TITLE_BASE: Record<RequestTypeId, string> = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Номер текущей пары списков. Пока единый для всех типов заявок — архивных пар ещё
|
||||
* нет, все формы работают с парой №1.
|
||||
*
|
||||
* Когда архивация заработает, номер будет приходить из approval-route (эндпоинт
|
||||
* «текущий список по типу заявки») и заполняться один раз при старте web part, до
|
||||
* загрузки чанка любой формы. Сигнатуры функций ниже при этом не меняются — поэтому
|
||||
* вызовы по формам править второй раз не придётся.
|
||||
* Номер партиции, используемый КОГДА РЕЕСТР НЕДОСТУПЕН (approval-route не ответил
|
||||
* при старте — см. initListPartitions). Первая пара списков существует всегда,
|
||||
* поэтому это безопасный фолбэк: портал продолжает принимать заявки, просто пишет
|
||||
* в партицию №1.
|
||||
*/
|
||||
const CURRENT_LIST_NUMBER = 1;
|
||||
const FALLBACK_LIST_NUMBER = 1;
|
||||
|
||||
/**
|
||||
* Суффикс списка данных. Копия того же регекса, что в
|
||||
* features/myRequests/myRequests.service.ts и в админке approval-route — три разных
|
||||
* места читают имена одних и тех же списков. Меняя здесь, менять и там.
|
||||
*/
|
||||
const DATA_LIST_SUFFIX = /\s*—\s*данные\s*$/;
|
||||
|
||||
function listTitleBase(id: RequestTypeId): string {
|
||||
const base = LIST_TITLE_BASE[id];
|
||||
@@ -50,12 +59,85 @@ function listTitleBase(id: RequestTypeId): string {
|
||||
return base;
|
||||
}
|
||||
|
||||
/** Имя списка данных заявок этого типа. */
|
||||
/**
|
||||
* Имя списка данных, в который идёт ЗАПИСЬ новых заявок этого типа.
|
||||
*
|
||||
* Источник правды — реестр партиций (approval-route знает, какая партиция текущая,
|
||||
* и сам переключает её, когда прежняя добирает порог). Берём именно фактический
|
||||
* Title из реестра, а не собираем его из базы и номера: так имя не может разойтись
|
||||
* с тем, что реально лежит в SharePoint (например, если список переименовали).
|
||||
*
|
||||
* Ключи LIST_TITLE_BASE совпадают с pageUrl типов заявок в approval-route — по нему
|
||||
* реестр и находит нужную серию.
|
||||
*/
|
||||
export function dataListTitle(id: RequestTypeId): string {
|
||||
return `${listTitleBase(id)} ${CURRENT_LIST_NUMBER} — данные`;
|
||||
const currentTitle = currentPartitionForPageUrl(id)?.title;
|
||||
if (currentTitle) return currentTitle;
|
||||
|
||||
return `${listTitleBase(id)} ${FALLBACK_LIST_NUMBER} — данные`;
|
||||
}
|
||||
|
||||
/** Имя списка согласования заявок этого типа. */
|
||||
/**
|
||||
* Имя парного списка согласования.
|
||||
*
|
||||
* Выводится из имени списка ДАННЫХ, а не собирается независимо: номер партиции у
|
||||
* пары обязан совпадать, а список данных — единственное, что реестр знает точно
|
||||
* (approval-route хранит ссылку только на него, согласование ищется по конвенции).
|
||||
*/
|
||||
export function approvalListTitle(id: RequestTypeId): string {
|
||||
return `${listTitleBase(id)} ${CURRENT_LIST_NUMBER} — согласование`;
|
||||
const dataTitle = dataListTitle(id);
|
||||
if (DATA_LIST_SUFFIX.test(dataTitle)) {
|
||||
return `${dataTitle.replace(DATA_LIST_SUFFIX, '')} — согласование`;
|
||||
}
|
||||
|
||||
// Имя списка данных не по конвенции (переименовали вручную) — вывести парное
|
||||
// имя из него нельзя, собираем по базе, чтобы вернуть хоть что-то осмысленное.
|
||||
console.warn(
|
||||
`[Партиции списков] «${dataTitle}» не оканчивается на «— данные» — имя списка согласования собрано по конвенции`
|
||||
);
|
||||
return `${listTitleBase(id)} ${FALLBACK_LIST_NUMBER} — согласование`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Имя списка данных КОНКРЕТНОЙ партиции по её listId — для открытия/сохранения
|
||||
* заявки, которая не обязательно лежит в текущей партиции (архив).
|
||||
*
|
||||
* Бросает ТОЛЬКО когда реестр ТОЧНО знает, что такого listId в серии нет
|
||||
* (isConfirmedStaleListId) — тогда мы рискуем прочитать/записать не тот список,
|
||||
* и правильный ответ — явная ошибка, а не молчаливый откат.
|
||||
*
|
||||
* Если же реестр про серию просто ничего не знает (approval-route не ответил
|
||||
* при старте) — это НЕ повод бросать: откатываемся на ту же конвенцию по номеру,
|
||||
* что и dataListTitle() для текущей партиции без реестра. Сам listId при этом не
|
||||
* используется как источник данных (данные читаются им напрямую, см. load.ts
|
||||
* каждой формы) — здесь он нужен только чтобы вывести ИМЯ списка согласования
|
||||
* по конвенции, а на неё влияет лишь номер партиции, не сам url.
|
||||
*/
|
||||
export function dataListTitleForListId(id: RequestTypeId, listId: string): string {
|
||||
const partition = partitionForPageUrlAndListId(id, listId);
|
||||
if (partition?.title) return partition.title;
|
||||
|
||||
if (isConfirmedStaleListId(id, listId)) {
|
||||
throw new Error(
|
||||
`Список «${listId}» не найден в реестре партиций типа заявки «${id}» — возможно, ссылка устарела.`
|
||||
);
|
||||
}
|
||||
|
||||
// Партиция нашлась, но Title не закеширован (см. dataListTitle), либо реестр
|
||||
// про серию вообще ничего не знает — оба случая закрываются одним фолбэком.
|
||||
return `${listTitleBase(id)} ${partition?.number ?? FALLBACK_LIST_NUMBER} — данные`;
|
||||
}
|
||||
|
||||
/** Имя парного списка согласования конкретной партиции — см. dataListTitleForListId. */
|
||||
export function approvalListTitleForListId(id: RequestTypeId, listId: string): string {
|
||||
const dataTitle = dataListTitleForListId(id, listId);
|
||||
if (DATA_LIST_SUFFIX.test(dataTitle)) {
|
||||
return `${dataTitle.replace(DATA_LIST_SUFFIX, '')} — согласование`;
|
||||
}
|
||||
|
||||
const partition = partitionForPageUrlAndListId(id, listId);
|
||||
console.warn(
|
||||
`[Партиции списков] «${dataTitle}» не оканчивается на «— данные» — имя списка согласования собрано по конвенции`
|
||||
);
|
||||
return `${listTitleBase(id)} ${partition?.number ?? FALLBACK_LIST_NUMBER} — согласование`;
|
||||
}
|
||||
|
||||
@@ -52,6 +52,15 @@ export interface IRoute {
|
||||
formKey?: string;
|
||||
/** Id заявки в списке данных — для 'approve' и 'edit'. */
|
||||
itemId?: number;
|
||||
/**
|
||||
* Серверный относительный url КОНКРЕТНОЙ партиции, где лежит заявка — для
|
||||
* 'approve' и 'edit'. Списки заявок нумерованные (см. shared/approval/
|
||||
* listPartitions.ts): без этого поля заявка всегда открывалась бы так, будто
|
||||
* лежит в ТЕКУЩЕЙ партиции, и архивная заявка читалась/сохранялась бы не в
|
||||
* тот список. Не задан — значит партиция неизвестна (старая ссылка без
|
||||
* параметра), и форма/просмотр откатываются на текущую партицию.
|
||||
*/
|
||||
listId?: string;
|
||||
/** Id исходной заявки для предзаполнения — для 'create' (напр. орг. места на основе прием). */
|
||||
sourceId?: number;
|
||||
/** Подвкладка «Моих заявок»; задана только при nav='own'. */
|
||||
@@ -263,6 +272,7 @@ export function parseRoute(search: string = window.location.search): IRoute {
|
||||
const formKey = params.get('rptype') ?? undefined;
|
||||
const idRaw = params.get('rpid');
|
||||
const itemId = idRaw ? Number(idRaw) : undefined;
|
||||
const listId = params.get('rplist') ?? undefined;
|
||||
const sourceRaw = params.get('rpsource');
|
||||
const sourceId = sourceRaw ? Number(sourceRaw) : undefined;
|
||||
|
||||
@@ -288,11 +298,11 @@ export function parseRoute(search: string = window.location.search): IRoute {
|
||||
// страницей: закрыв панель, пользователь должен вернуться ровно в тот же
|
||||
// список и на то же место в нём.
|
||||
if (mode === 'approve' && formKey && itemId && !isNaN(itemId)) {
|
||||
return { mode: 'approve', nav, formKey, itemId, ownTab, ownStatus, page, query };
|
||||
return { mode: 'approve', nav, formKey, itemId, listId, ownTab, ownStatus, page, query };
|
||||
}
|
||||
// Редактирование собственной незавершённой заявки — та же панель, но с формой.
|
||||
if (mode === 'edit' && formKey && itemId && !isNaN(itemId)) {
|
||||
return { mode: 'edit', nav, formKey, itemId, ownTab, ownStatus, page, query };
|
||||
return { mode: 'edit', nav, formKey, itemId, listId, ownTab, ownStatus, page, query };
|
||||
}
|
||||
// Создание производной заявки с предзаполнением из исходной.
|
||||
if (formKey && sourceId && !isNaN(sourceId)) {
|
||||
@@ -308,6 +318,7 @@ function buildQuery(route: IRoute): string {
|
||||
params.delete('rpnav');
|
||||
params.delete('rptype');
|
||||
params.delete('rpid');
|
||||
params.delete('rplist');
|
||||
params.delete('rpsource');
|
||||
params.delete('rptab');
|
||||
params.delete('rpstatus');
|
||||
@@ -331,6 +342,9 @@ function buildQuery(route: IRoute): string {
|
||||
params.set('rpmode', route.mode);
|
||||
params.set('rptype', route.formKey);
|
||||
params.set('rpid', String(route.itemId));
|
||||
// Партиция неизвестна (архивная ссылка старого формата, до этого поля) —
|
||||
// ссылка всё равно валидна, форма/просмотр просто откатятся на текущую.
|
||||
if (route.listId) params.set('rplist', route.listId);
|
||||
} else if (route.mode === 'create' && route.formKey && route.sourceId) {
|
||||
params.set('rptype', route.formKey);
|
||||
params.set('rpsource', String(route.sourceId));
|
||||
|
||||
Reference in New Issue
Block a user