feat: changed lists columns

This commit is contained in:
Тимур Городецкий
2026-08-06 15:36:22 +05:00
24 changed files with 1704 additions and 497 deletions
@@ -12,8 +12,12 @@ import { APPROVAL_PHASE, REQUEST_STATUS } from './models/statuses';
// Внутренние имена колонок списка согласования (создаются провижинингом).
const FIELDS = {
approver: 'Approver', // «Согласующий» (User) → ApproverId
status: 'Status', // «Статус» (Choice)
// Внутренние имена с префиксом группы колонок сайта (TansuSite) и «Request» —
// так они заведены в SharePoint. Из них SharePoint выводит имена managed
// properties для поиска: TansuSiteApproverOWSUSER и RequestStatusOWSCHCS
// (см. MP_APPROVER/MP_STATUS в features/myRequests/myRequests.service.ts).
approver: 'TansuSiteApprover', // «Согласующий» (User) → TansuSiteApproverId
status: 'RequestStatus', // «Статус» (Choice)
ticket: 'Ticket', // «Заявка» (Lookup) → TicketId
date: 'Date', // «Дата» (DateTime)
comment: 'Comment', // «Комментарий» (Text)
@@ -28,6 +32,12 @@ export interface IApproverToWrite {
email: string;
position: string;
allowReassign: boolean;
/** Порядок (StepOrder). Не задан — берётся позиция в массиве (orderStart + индекс).
* Несколько согласующих с ОДИНАКОВЫМ order — параллельный шаг: все становятся
* «На согласовании» одновременно (см. createSteps). Сейчас редактор матрицы
* (ApprovalMatrix/useMatrixEditor) всегда даёт уникальный order по позиции —
* это поле для параллельных шагов, приходящих из внешнего маршрута напрямую. */
order?: number;
}
// Все запросы к списку «<форма> — согласование». listTitle задаётся формой.
@@ -38,9 +48,14 @@ export class ApprovalListService {
return getSP().web.lists.getByTitle(this.listTitle);
}
// Пишет по строке на каждого участника фазы с указанным статусом.
// Порядок (Order) = orderStart + позиция в массиве (фазы идут подряд в одном списке:
// сначала согласующие 1..N, затем исполнители N+1..N+M).
// Пишет по строке на каждого участника фазы. Порядок (Order) = a.order, если
// задан, иначе orderStart + позиция в массиве (фазы идут подряд в одном
// списке: сначала согласующие 1..N, затем исполнители N+1..N+M).
//
// Статус пишется переданным значением ОДИНАКОВО для всех строк партии — кто
// сейчас «на очереди», а кто «ждёт», решает отдельно activateNextQueued
// (вызывается сразу после createSteps в approvalSubmission.service.ts/writeRoute
// и в approverDecision.service.ts/decide).
public async createSteps(
requestId: number,
approvers: IApproverToWrite[],
@@ -48,22 +63,65 @@ export class ApprovalListService {
phase: string = APPROVAL_PHASE.approval,
orderStart: number = 1
): Promise<void> {
for (let i = 0; i < approvers.length; i++) {
const a = approvers[i];
if (!a.email) continue;
const withOrder = approvers
.map((a, i) => ({ a, order: a.order ?? orderStart + i }))
.filter((x) => !!x.a.email);
for (const { a, order } of withOrder) {
const approverId = await this.resolveUserId(a.email);
await this.list().items.add({
Title: a.position || a.email,
[`${FIELDS.approver}Id`]: approverId,
[FIELDS.status]: status,
[`${FIELDS.ticket}Id`]: requestId,
[FIELDS.order]: orderStart + i,
[FIELDS.order]: order,
[FIELDS.allowReassign]: a.allowReassign,
[FIELDS.phase]: phase,
});
}
}
// Продвигает очередь: среди ещё не активных строк со статусом «Ждёт очереди»
// в текущей фазе активирует те, что делят наименьший StepOrder (обычно одна
// строка; несколько — параллельный шаг). Фаза исполнения активируется, только
// когда фаза согласования пройдена целиком (без Отклонено/Возвращена) — иначе
// строки исполнения остаются «Ждёт очереди». Если фаза согласования уже
// остановлена решением, ничего не активирует.
public async activateNextQueued(requestId: number): Promise<void> {
const rows = await this.getForRequest(requestId);
const approvalRows = rows.filter((r) => r.phase !== APPROVAL_PHASE.execution);
const execRows = rows.filter((r) => r.phase === APPROVAL_PHASE.execution);
const approvalStopped = approvalRows.some(
(r) => r.status === REQUEST_STATUS.rejected || r.status === REQUEST_STATUS.returned
);
if (approvalStopped) return;
const approvalDone =
approvalRows.length === 0 || approvalRows.every((r) => r.status === REQUEST_STATUS.approved);
const activate = async (phaseRows: IApprovalRow[], activeStatus: string): Promise<void> => {
if (phaseRows.some((r) => r.status === activeStatus)) return;
const queuedRows = phaseRows.filter((r) => r.status === REQUEST_STATUS.queued);
if (queuedRows.length === 0) return;
const minOrder = Math.min(...queuedRows.map((r) => r.order));
await Promise.all(
queuedRows.filter((r) => r.order === minOrder).map((r) => this.setStatus(r.id, activeStatus))
);
};
if (!approvalDone) {
await activate(approvalRows, REQUEST_STATUS.onApproval);
} else {
await activate(execRows, REQUEST_STATUS.onExecution);
}
}
// Точечное обновление статуса одной строки маршрута.
public async setStatus(rowId: number, status: string): Promise<void> {
await this.list().items.getById(rowId).update({ [FIELDS.status]: status });
}
// Добавляет одну строку согласования с заданным Order (для делегата).
public async addApproverRow(
requestId: number,
@@ -187,19 +245,6 @@ export class ApprovalListService {
});
}
// Активирует следующий шаг маршрута: первую по очереди строку со статусом
// «Ждёт очереди» переводит в активный статус её фазы (Согласование → «На
// согласовании», Исполнение → «На исполнении»). Если ждущих нет — ничего.
// Вызывается при отправке (активировать первый шаг) и после каждого решения.
public async activateNextQueued(requestId: number): Promise<void> {
const rows = await this.getForRequest(requestId); // отсортированы по StepOrder
const next = rows.filter((r) => r.status === REQUEST_STATUS.queued)[0];
if (!next) return;
const active =
next.phase === APPROVAL_PHASE.execution ? REQUEST_STATUS.onExecution : REQUEST_STATUS.onApproval;
await this.list().items.getById(next.id).update({ [FIELDS.status]: active });
}
// Удаляет все строки согласования по заявке (при возврате/повторной отправке).
public async deleteForRequest(requestId: number): Promise<void> {
const rows = await this.getForRequest(requestId);
@@ -43,13 +43,24 @@ export async function writeApprovers(
status: string
): Promise<void> {
await approval.deleteForRequest(requestId);
await approval.createSteps(requestId, approvers, status, APPROVAL_PHASE.approval, 1);
const submitted = status === REQUEST_STATUS.onApproval;
const rowStatus = submitted ? REQUEST_STATUS.queued : status;
await approval.createSteps(requestId, approvers, rowStatus, APPROVAL_PHASE.approval, 1);
if (submitted) {
await approval.activateNextQueued(requestId);
}
}
// Записать ОБЕ фазы маршрута заявки: согласующих (Order 1..N) и исполнителей
// (Order N+1..N+M) в один список. Исполнители пишутся сразу при отправке —
// стороне согласующего внешний API уже не нужен, переход в фазу исполнения
// делает computeRequestStatus, когда все согласующие одобрили.
//
// Исполнители стартуют ВСЕ как «Ждёт очереди» (rowStatus, та же логика, что и
// у согласующих) — до прохождения фазы согласования у исполнителей нет текущей
// очереди. Первого исполнителя переключит на «На исполнении»
// approval.activateNextQueued, когда фаза согласования будет пройдена целиком
// (см. approverDecision.service.ts/decide).
export async function writeRoute(
approval: ApprovalListService,
requestId: number,
@@ -32,17 +32,20 @@ export class ApproverService {
) {}
// Ожидающая строка текущего пользователя, если сейчас его очередь; иначе undefined.
// Последовательное согласование: активна первая строка со статусом «На согласовании».
// Очередь уже проставлена на запись (см. createSteps/advanceQueue) — активна
// ровно одна строка со статусом «На согласовании» на фазу, искать «первую по
// порядку» не нужно.
public async getPendingRowForCurrentUser(requestId: number): Promise<IApprovalRow | undefined> {
const rows = await this.approval.getForRequest(requestId);
const userId = await this.approval.getCurrentUserId();
// Активна первая незакрытая строка по очереди: согласующий («На согласовании»)
// или, когда согласование пройдено, исполнитель («На исполнении»).
const firstPending = rows.filter(
// Активные строки текущего шага: согласующий («На согласовании») или, когда
// согласование пройдено, исполнитель («На исполнении»). При параллельном шаге
// (одинаковый StepOrder) активных строк может быть несколько сразу — ищем
// среди ВСЕХ активных, а не только среди первой по порядку.
const active = rows.filter(
(r) => r.status === REQUEST_STATUS.onApproval || r.status === REQUEST_STATUS.onExecution
)[0];
if (firstPending && firstPending.approverId === userId) return firstPending;
return undefined;
);
return active.filter((r) => r.approverId === userId)[0];
}
// Делегирование: согласующий добавляет делеганта ПЕРЕД собой (Order — между
@@ -59,22 +62,24 @@ export class ApproverService {
const prevOrder = before.length > 0 ? before[before.length - 1].order : current.order - 1;
const newOrder = (prevOrder + current.order) / 2;
const position = await this.approval.getJobTitle(user.loginName);
// Делегат наследует фазу той строки, вместо которой действует, и её статус
// ожидания (согласование → «На согласовании», исполнение → «На исполнении»).
const pendingStatus =
current.phase === APPROVAL_PHASE.execution
? REQUEST_STATUS.onExecution
: REQUEST_STATUS.onApproval;
// Делегат наследует фазу той строки, вместо которой согласует (согласование/исполнение).
// Обе строки — делегат и прежний согласующий — уходят в «Ждёт очереди»,
// activateNextQueued затем активирует делегата (у него меньший order).
await this.approval.addApproverRow(
requestId,
{ email: user.email, position, allowReassign: false },
newOrder,
pendingStatus,
REQUEST_STATUS.queued,
current.phase
);
await this.approval.setStatus(current.id, REQUEST_STATUS.queued);
await this.approval.activateNextQueued(requestId);
emitRequestsChanged();
}
// Применяет решение: обновляет строку и пересчитывает статус заявки.
// Применяет решение: обновляет строку, продвигает очередь (на следующего
// согласующего внутри фазы либо на первого исполнителя, если фаза согласования
// только что завершилась целиком) и пересчитывает статус заявки.
// Возвращает новый статус заявки.
public async decide(
requestId: number,
@@ -2,6 +2,7 @@
export {
REQUEST_STATUS,
REQUEST_STATUS_CHOICES,
APPROVAL_ROW_STATUS_CHOICES,
APPROVAL_PHASE,
APPROVAL_PHASE_CHOICES,
EDITABLE_STATUSES,
@@ -3,18 +3,28 @@
// В слое формы кодом ставятся Draft (черновик) и OnApproval (отправка на согласование);
// Approved/Rejected/Returned выставляются при решениях согласующих; Execution/Closed —
// зарезервированы под будущие этапы.
// Статусы СТРОК списка «— согласование» (не заявки в контентном списке):
// draft/rejected/returned — общие с заявкой; остальные — только для строк
// маршрута, по фазам:
// Согласование: queued («Ждёт очереди») → onApproval («На согласовании») → approved/rejected/returned.
// Исполнение: queued → onExecution («На исполнении») → executed («Выполнено»).
// Активация — только через ApprovalListService.activateNextQueued (вызывается
// при отправке заявки и после каждого положительного решения, см.
// approvalSubmission.service.ts/writeRoute, approverDecision.service.ts/decide):
// среди строк со статусом queued в текущей фазе находит наименьший StepOrder
// и переводит её (или несколько, если делят один order — параллельный шаг) в
// onApproval/onExecution. Так «чья сейчас очередь» — просто значение колонки
// Status, без пересчёта порядка на клиенте.
export const REQUEST_STATUS = {
draft: 'Черновик',
onApproval: 'На согласовании',
queued: 'Ждёт очереди',
onExecution: 'На исполнении',
executed: 'Выполнено',
approved: 'Согласовано',
rejected: 'Отклонено',
returned: 'Возвращена на доработку',
// Статус СТРОКИ маршрута, до которой ещё не дошла очередь (активен только текущий шаг).
queued: 'Ждёт очереди',
// Статусы СТРОК фазы исполнения: активная очередь исполнителя и выполнено.
onExecution: 'На исполнении',
executed: 'Выполнено',
// Статусы ЗАЯВКИ на этапе исполнения.
// Статус ЗАЯВКИ (не строки) на этапе исполнения.
execution: 'Выполнение',
closed: 'Закрыта',
} as const;
@@ -33,20 +43,40 @@ export function isEditableStatus(status: string | undefined): boolean {
return !status || EDITABLE_STATUSES.indexOf(status) !== -1;
}
// Полный набор значений для Choice-колонки Status при провижининге.
// Наборов значений ДВА, и они не совпадают — это разные сущности:
//
// 1) статус ЗАЯВКИ — колонка Status в списке «… — данные». Пишется формой
// (Черновик/На согласовании) и computeRequestStatus после каждого решения.
// «Ждёт очереди»/«На исполнении»/«Выполнено» здесь не бывают: это состояния
// отдельного шага маршрута, а не заявки целиком.
//
// 2) статус СТРОКИ МАРШРУТА — колонка RequestStatus в списке «… — согласование».
// Пишется createSteps/activateNextQueued/setDecision. «Выполнение»/«Закрыта»
// здесь не бывают: это агрегатные состояния заявки, у отдельного шага
// смысла не имеют.
//
// Держать их в синхроне с forms/*/provisioning.json и forms/*/approval/provisioning.json.
export const REQUEST_STATUS_CHOICES: string[] = [
REQUEST_STATUS.draft,
REQUEST_STATUS.onApproval,
REQUEST_STATUS.approved,
REQUEST_STATUS.rejected,
REQUEST_STATUS.returned,
REQUEST_STATUS.queued,
REQUEST_STATUS.onExecution,
REQUEST_STATUS.executed,
REQUEST_STATUS.execution,
REQUEST_STATUS.closed,
];
export const APPROVAL_ROW_STATUS_CHOICES: string[] = [
REQUEST_STATUS.draft,
REQUEST_STATUS.queued,
REQUEST_STATUS.onApproval,
REQUEST_STATUS.onExecution,
REQUEST_STATUS.approved,
REQUEST_STATUS.executed,
REQUEST_STATUS.rejected,
REQUEST_STATUS.returned,
];
// Фаза строки в списке маршрута: согласование (кто одобряет) и исполнение
// (кто выполняет после согласования). Обе фазы живут в одном списке «— согласование»,
// строки различаются колонкой Phase. Значения приходят из внешнего API как
+21 -1
View File
@@ -4,9 +4,18 @@ import '@pnp/sp/lists';
import '@pnp/sp/items';
import '@pnp/sp/attachments';
import '@pnp/sp/site-users/web';
import { BrowserFetchWithRetry } from '@pnp/queryable';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { DATA_SITE_URL } from '../config';
// Мягкая ретрай-политика вместо стандартной PnPjs (retries: 3, interval: 200).
// Дефолт слишком напорист: при отказе (429 «слишком много запросов», 503) он
// начинает повторять почти сразу, что при массовой загрузке только усугубляет
// throttling со стороны SharePoint. Здесь меньше попыток и заметно длиннее
// первая пауза; интервал в PnPjs удваивается с каждой попыткой, так что выходит
// примерно 1.5с → 3с.
const RETRY = { retries: 2, interval: 1500 };
// Единый инициализированный клиент PnPjs на весь web part.
let _sp: SPFI | undefined;
@@ -16,7 +25,18 @@ let _sp: SPFI | undefined;
* списки живут там, где бы ни стоял сам web part. Авторизация берётся из context.
*/
export function initSP(context: WebPartContext): void {
_sp = spfi(DATA_SITE_URL).using(SPFx(context));
_sp = spfi(DATA_SITE_URL).using(SPFx(context), BrowserFetchWithRetry(RETRY));
}
/**
* Клиент для ПРОИЗВОЛЬНОГО сайта (списки типов заявок живут на разных сайтах,
* см. approval-route API). В отличие от getSP() не кэшируется — адрес сайта
* приходит из данных. Ретрай-политика та же, что и у основного клиента:
* BrowserFetchWithRetry идёт ПОСЛЕ SPFx, поэтому заменяет его обработчик
* отправки (у behavior replace: true по умолчанию).
*/
export function spForSite(siteUrl: string, context: WebPartContext): SPFI {
return spfi(siteUrl).using(SPFx(context), BrowserFetchWithRetry(RETRY));
}
/** Получить инициализированный клиент. Бросает, если initSP не был вызван. */
+4 -3
View File
@@ -3,7 +3,8 @@ import * as React from 'react';
// Маршрутизация портала по параметрам URL.
//
// Раздел бокового меню — rpnav: 'new' (подать заявку, по умолчанию),
// 'approvals' (ждут моего решения), 'own' (мои заявки).
// 'approvals' (ждут моего решения), 'own' (мои заявки), 'search' (поиск
// сразу по обеим категориям — и по своим заявкам, и по тем, где участвовал).
// Открытая заявка — переход по ссылке из потока Power Automate или клик по
// строке таблицы:
// …/СтраницаПортала.aspx?rpmode=approve&rptype=<форма>&rpid=<RowID>
@@ -22,7 +23,7 @@ import * as React from 'react';
export type PortalMode = 'submit' | 'approve' | 'create';
/** Раздел бокового меню. */
export type NavSection = 'new' | 'approvals' | 'own';
export type NavSection = 'new' | 'approvals' | 'own' | 'search';
export interface IRoute {
mode: PortalMode;
@@ -39,7 +40,7 @@ export interface IRoute {
const ROUTE_CHANGE_EVENT = 'rp-route-change';
function parseNav(raw: string | undefined): NavSection {
if (raw === 'approvals' || raw === 'own') return raw;
if (raw === 'approvals' || raw === 'own' || raw === 'search') return raw;
return 'new';
}
@@ -0,0 +1,76 @@
import * as React from 'react';
// Текст поиска переживает перезагрузку страницы (localStorage), но не более
// того — это просто «продолжить с того же места», а не синхронизация между
// вкладками/устройствами. key должен быть уникальным на раздел (см. вызовы в
// RequestsTableView/SearchView), иначе значения разных таблиц перепишут друг друга.
function readStored(storageKey: string): string {
try {
return window.localStorage.getItem(storageKey) || '';
} catch {
return '';
}
}
export function usePersistedQuery(key: string): [string, (value: string) => void] {
const storageKey = `rp-search-query:${key}`;
const [value, setValue] = React.useState<string>(() => readStored(storageKey));
// Компонент может продолжать жить, когда key поменялся: RequestsTableView —
// это один и тот же элемент для вкладок «На согласовании» и «Мои заявки»,
// меняется только проп kind. Инициализатор useState при этом повторно НЕ
// срабатывает, поэтому без перечитывания текст из одной вкладки оставался бы
// на экране во второй и записывался уже под её ключ — выглядело бы так, будто
// ключ у них общий.
const prevKey = React.useRef<string>(storageKey);
React.useEffect(() => {
if (prevKey.current === storageKey) return;
prevKey.current = storageKey;
setValue(readStored(storageKey));
}, [storageKey]);
const setPersisted = React.useCallback(
(next: string): void => {
setValue(next);
try {
if (next) {
window.localStorage.setItem(storageKey, next);
} else {
window.localStorage.removeItem(storageKey);
}
} catch {
// localStorage недоступен (приватный режим и т.п.) — просто не сохраняем.
}
},
[storageKey]
);
return [value, setPersisted];
}
// Задержка перед тем, как введённый текст уйдёт в запрос. Без неё поиск
// срабатывает на КАЖДОЕ нажатие клавиши: «согласование» — это 12 полных циклов
// (Search + дочитывание через REST по каждому типу заявок), из которых нужен
// только последний. Заодно снимает часть нагрузки, из-за которой SharePoint
// начинает throttling'ить (429).
const SEARCH_DEBOUNCE_MS = 450;
// Значение, отстающее от переданного на SEARCH_DEBOUNCE_MS: в поле ввода текст
// меняется сразу (без «залипания»), а в запрос уходит только когда пользователь
// перестал печатать. Пустая строка проходит без задержки — очистка поиска
// должна возвращать полный список мгновенно.
export function useDebouncedQuery(value: string, delayMs: number = SEARCH_DEBOUNCE_MS): string {
const [debounced, setDebounced] = React.useState<string>(value);
React.useEffect(() => {
if (!value) {
setDebounced('');
return undefined;
}
const timer = window.setTimeout(() => setDebounced(value), delayMs);
return () => window.clearTimeout(timer);
}, [value, delayMs]);
return debounced;
}