diff --git a/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx b/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx
index 2198103..aefb240 100644
--- a/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx
+++ b/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx
@@ -12,6 +12,7 @@ import {
getLinkedTypes,
getOwnRequests,
getOwnRequestsByStatus,
+ hydrateCurrentAssignees,
hydrateDecisionComments,
hydratePendingAuthors,
searchOwnRequests,
@@ -131,11 +132,32 @@ function decisionStatusFor(view: ListView, statusFilter: string): string | undef
interface ITableColumns {
author: boolean;
status: boolean;
+ /** Колонка «Исполнитель» — у кого заявка сейчас (см. showsAssignee). */
+ assignee: boolean;
/** Заголовок колонки с комментарием; undefined — колонки нет. */
commentLabel?: string;
}
+// «Исполнитель» нужен там, где заявка может быть у кого угодно: свои поданные
+// заявки, история своих решений (заявка ушла дальше по маршруту) и «Все
+// заявки». В очередях согласования/исполнения и в «Доработке» держатель —
+// всегда сам пользователь, в «Отклонённых мной» — никто: колонка вышла бы из
+// одинаковых значений.
+//
+// На «Инициированных» с фильтром по статусу — только если при этом статусе у
+// заявки вообще бывает держатель по маршруту. При «Возвращена на доработку»
+// это всегда сам автор, при итоговых статусах — никто.
+const ASSIGNEE_VIEWS: ListView[] = ['own:created', 'own:approved', 'own:executed', 'all'];
+const STATUSES_WITH_ROUTE_HOLDER: string[] = [REQUEST_STATUS.onApproval, REQUEST_STATUS.execution];
+
+function showsAssignee(view: ListView, statusFilter: string): boolean {
+ if (ASSIGNEE_VIEWS.indexOf(view) < 0) return false;
+ if (view === 'own:created' && statusFilter) return STATUSES_WITH_ROUTE_HOLDER.indexOf(statusFilter) >= 0;
+ return true;
+}
+
function columnsFor(view: ListView, statusFilter: string): ITableColumns {
+ const assignee = showsAssignee(view, statusFilter);
const decisionStatus = decisionStatusFor(view, statusFilter);
const commentLabel =
decisionStatus === REQUEST_STATUS.rejected
@@ -144,15 +166,15 @@ function columnsFor(view: ListView, statusFilter: string): ITableColumns {
? 'Причина возврата'
: undefined;
- if (view === 'approvals' || view === 'execution') return { author: true, status: false };
- if (view === 'rework') return { author: false, status: false, commentLabel };
- if (view === 'own:created') return { author: false, status: !statusFilter, commentLabel };
+ if (view === 'approvals' || view === 'execution') return { author: true, status: false, assignee };
+ if (view === 'rework') return { author: false, status: false, assignee, commentLabel };
+ if (view === 'own:created') return { author: false, status: !statusFilter, assignee, commentLabel };
// 'all' — заявки всех авторов вперемешку, поэтому нужны и автор, и статус.
- if (view === 'all') return { author: true, status: true };
+ if (view === 'all') return { author: true, status: true, assignee };
// История решений: заявка чужая, поэтому нужен автор. Статус — только если он
// не предопределён самой вкладкой («Отклонённые мной» — это всегда «Отклонено»,
// а вот у согласованных/выполненных заявка могла уйти дальше по маршруту).
- return { author: true, status: !decisionStatus, commentLabel };
+ return { author: true, status: !decisionStatus, assignee, commentLabel };
}
export interface IRequestsTableViewProps {
@@ -189,6 +211,11 @@ function formatDate(iso: string): string {
return `${dd}.${mm}.${d.getFullYear()}`;
}
+/** Человек в ячейке — «ФИО · почта»; колонки «Автор» и «Исполнитель». */
+function personLabel(name: string, email: string): string {
+ return [name, email].filter(Boolean).join(' · ') || '—';
+}
+
// Таблица заявок. Клик в ЛЮБОМ месте строки открывает заявку (кликабельна вся
// строка, а не только название), поэтому строка —
с onClick + tabIndex
// (доступ с клавиатуры: Enter/Space).
@@ -236,7 +263,7 @@ const Table: React.FC<{
key: 'author',
title: 'Автор',
width: '280px',
- render: (r) => [r.authorName, r.authorEmail].filter(Boolean).join(' · ') || '—'
+ render: (r) => personLabel(r.authorName, r.authorEmail)
});
}
if (cols.status) {
@@ -247,6 +274,22 @@ const Table: React.FC<{
render: (r) => (r.status ? : '—')
});
}
+ if (cols.assignee) {
+ columns.push({
+ key: 'assignee',
+ title: 'Исполнитель',
+ // Та же ширина, что у «Автора»: содержимое того же вида.
+ width: '280px',
+ // «ФИО · почта», как в колонке «Автор». Параллельный шаг — каждый
+ // человек отдельной строкой.
+ render: (r) =>
+ r.currentAssignees && r.currentAssignees.length > 0
+ ? r.currentAssignees.map((a) => (
+ {personLabel(a.name, a.email)}
+ ))
+ : '—'
+ });
+ }
if (cols.commentLabel) {
columns.push({
key: 'comment',
@@ -522,9 +565,11 @@ export const RequestsTableView: React.FC = ({
const fetchPage = async (onPartial?: (p: IPage) => void, reuseFull = false): Promise => {
const result = await fetchRows(onPartial, reuseFull);
const decisionStatus = decisionStatusFor(view, statusFilter);
- if (decisionStatus) {
- await hydrateDecisionComments(context, types, result.rows, decisionStatus);
- }
+ // Оба дочитывания из строк маршрута независимы — параллельно.
+ await Promise.all([
+ decisionStatus ? hydrateDecisionComments(context, types, result.rows, decisionStatus) : undefined,
+ showsAssignee(view, statusFilter) ? hydrateCurrentAssignees(context, types, result.rows) : undefined,
+ ]);
return result;
};
diff --git a/src/webparts/requestPortal/features/myRequests/models/IMyRequest.ts b/src/webparts/requestPortal/features/myRequests/models/IMyRequest.ts
index 807ed68..80bef3e 100644
--- a/src/webparts/requestPortal/features/myRequests/models/IMyRequest.ts
+++ b/src/webparts/requestPortal/features/myRequests/models/IMyRequest.ts
@@ -1,3 +1,9 @@
+/** Человек, у которого сейчас заявка (колонка «Исполнитель»). */
+export interface IAssignee {
+ name: string;
+ email: string;
+}
+
// Строка заявки пользователя для боковой панели «Мои заявки».
// Собирается из списка данных SharePoint конкретного типа заявки (см. myRequests.service.ts).
export interface IMyRequest {
@@ -61,4 +67,13 @@ export interface IMyRequest {
* там, где показывается (см. hydrateDecisionComments).
*/
decisionComment?: string;
+ /**
+ * Те, у кого заявка сейчас (колонка «Исполнитель»): согласующий или
+ * исполнитель текущего шага маршрута (несколько — параллельный шаг), при
+ * возврате на доработку — инициатор. Пустой массив — держателя нет (черновик,
+ * итоговый статус); undefined — не дочитывалось. Как и decisionComment, живёт
+ * в строках маршрута и дочитывается только там, где показывается (см.
+ * hydrateCurrentAssignees).
+ */
+ currentAssignees?: IAssignee[];
}
diff --git a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts
index 85a46c4..36c2468 100644
--- a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts
+++ b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts
@@ -24,7 +24,7 @@ import {
} from '../../shared/approval';
import { effectiveUserEmail } from '../../shared/impersonation';
import { DATA_SITE_URL } from '../../shared/config';
-import { IMyRequest } from './models/IMyRequest';
+import { IAssignee, IMyRequest } from './models/IMyRequest';
import { IMyRequestsTypeDebug } from './models/IMyRequestsDebug';
import { traceLinkedTypes, traceResultRows, traceSearchHits } from './searchTrace';
import { perfScope, traced } from '../../shared/perfTrace';
@@ -1581,9 +1581,10 @@ export async function getAllOwnRequests(
return Array.from(byKey.values()).sort((a, b) => b.created.localeCompare(a.created));
}
-// Сколько строк маршрута максимум забираем за раз при дочитывании комментариев:
-// на странице ≤20 заявок, у каждой обычно единицы шагов — запас многократный.
-const DECISION_COMMENT_ROW_LIMIT = 2000;
+// Сколько строк маршрута максимум забираем за раз при дочитывании по странице
+// (комментарий к решению, текущий исполнитель): на странице ≤20 заявок, у
+// каждой обычно единицы шагов — запас многократный.
+const PAGE_ROUTE_ROW_LIMIT = 2000;
/**
* Проставляет decisionComment — комментарий согласующего к его решению по
@@ -1613,19 +1614,34 @@ export async function hydrateDecisionComments(
);
}
-async function hydrateDecisionCommentsInner(
+/** Заявки страницы из одной партиции + список согласования этой партиции. */
+interface IApprovalPartitionBatch {
+ approvalList: IList;
+ target: IListTarget;
+ typeName: string;
+ rows: IMyRequest[];
+ /** OData-условие «TicketId — одна из заявок пачки», уже в скобках. */
+ ticketFilter: string;
+}
+
+/**
+ * Разложить заявки по партициям и найти список согласования каждой — общая
+ * часть всех дочитываний из строк маршрута (комментарий к решению, текущий
+ * исполнитель).
+ *
+ * По ПАРТИЦИИ, а не по типу: строки маршрута заявки лежат в списке
+ * согласования той же партиции, что и сама заявка. Для архивной заявки
+ * «список согласования её типа» — это чужой список текущей партиции, где по
+ * её TicketId нашлись бы либо ничего, либо строки другой заявки.
+ */
+async function approvalPartitionsFor(
context: WebPartContext,
types: IRequestType[],
- rows: IMyRequest[],
- decisionStatus: string
-): Promise {
+ rows: IMyRequest[]
+): Promise {
const typeById = new Map();
types.forEach((t) => typeById.set(t.id, t));
- // По ПАРТИЦИИ, а не по типу: строки маршрута заявки лежат в списке
- // согласования той же партиции, что и сама заявка. Для архивной заявки
- // «список согласования её типа» — это чужой список текущей партиции, где по
- // её TicketId нашлись бы либо ничего, либо комментарий к другой заявке.
const rowsByPartition = new Map();
rows.forEach((r) => {
const key = r.listId.toLowerCase();
@@ -1650,23 +1666,39 @@ async function hydrateDecisionCommentsInner(
}))
);
- await Promise.all(
- Array.from(rowsByPartition.values()).map(async ({ target, typeName, rows: typeRows }) => {
- try {
- const lookup = located.get(approvalListKey(target.siteUrl, target.listId));
- if (!lookup || lookup.kind !== 'ok') return;
- const approvalList = spForSite(target.siteUrl, context).web.lists.getByTitle(
- lookup.value.approvalListTitle
- );
+ const batches: IApprovalPartitionBatch[] = [];
+ rowsByPartition.forEach(({ target, typeName, rows: partitionRows }) => {
+ const lookup = located.get(approvalListKey(target.siteUrl, target.listId));
+ if (!lookup || lookup.kind !== 'ok') return;
+ batches.push({
+ approvalList: spForSite(target.siteUrl, context).web.lists.getByTitle(lookup.value.approvalListTitle),
+ target,
+ typeName,
+ rows: partitionRows,
+ // Скобки обязательны: у OData `and` связывает сильнее `or`, без них
+ // «(…) and ApprovalStatus eq …» у вызывающего разобралось бы как
+ // «TicketId eq 1 or (TicketId eq 2 and ApprovalStatus eq …)».
+ ticketFilter: `(${partitionRows.map((r) => `TicketId eq ${r.id}`).join(' or ')})`,
+ });
+ });
+ return batches;
+}
- // TicketId (OR-цепочка) в скобках обязателен: у OData `and` связывает
- // сильнее `or», без скобок фильтр разобрался бы как
- // «TicketId eq 1 or (TicketId eq 2 and ApprovalStatus eq …)».
- const ticketFilter = typeRows.map((r) => `TicketId eq ${r.id}`).join(' or ');
+async function hydrateDecisionCommentsInner(
+ context: WebPartContext,
+ types: IRequestType[],
+ rows: IMyRequest[],
+ decisionStatus: string
+): Promise {
+ const batches = await approvalPartitionsFor(context, types, rows);
+
+ await Promise.all(
+ batches.map(async ({ approvalList, target, typeName, rows: typeRows, ticketFilter }) => {
+ try {
const stepRows = (await approvalList.items
- .filter(`(${ticketFilter}) and ApprovalStatus eq '${decisionStatus}'`)
+ .filter(`${ticketFilter} and ApprovalStatus eq '${decisionStatus}'`)
.select('TicketId', 'Comment')
- .top(DECISION_COMMENT_ROW_LIMIT)()) as Array>;
+ .top(PAGE_ROUTE_ROW_LIMIT)()) as Array>;
const commentByTicket = new Map();
stepRows.forEach((r) => {
@@ -1694,6 +1726,88 @@ async function hydrateDecisionCommentsInner(
);
}
+// Статусы ЗАЯВКИ, при которых она у кого-то в работе по маршруту: тогда
+// держатель — человек из строки маршрута с текущей очередью.
+const IN_ROUTE_REQUEST_STATUSES: string[] = [REQUEST_STATUS.onApproval, REQUEST_STATUS.execution];
+
+// Статусы СТРОКИ маршрута «очередь дошла до этого человека» (см. createSteps/decide).
+const CURRENT_TURN_STEP_STATUSES: string[] = [REQUEST_STATUS.onApproval, REQUEST_STATUS.onExecution];
+
+// Внутреннее имя поля «Согласующий» строки маршрута (см. FIELDS.approver в
+// shared/approval/approvalList.service.ts). При делегировании в нём уже
+// делегат, поэтому отдельно смотреть DelegatedBy не нужно.
+const STEP_APPROVER_FIELD = 'TansuSiteApprover';
+
+/**
+ * Проставляет currentAssignees — кто сейчас держит заявку:
+ * - на согласовании / выполнении — люди из строк маршрута с текущей очередью
+ * (несколько — параллельный шаг);
+ * - возвращена на доработку — инициатор;
+ * - иначе (черновик, итоговые статусы) — никого.
+ *
+ * Только для видимой страницы, как и hydrateDecisionComments: строки маршрута
+ * по сотням заявок сразу не запросить (предел длины URL у $filter). Мутирует
+ * переданные строки.
+ */
+export async function hydrateCurrentAssignees(
+ context: WebPartContext,
+ types: IRequestType[],
+ rows: IMyRequest[]
+): Promise {
+ rows.forEach((r) => {
+ const isReturned = r.status === REQUEST_STATUS.returned && (r.authorName || r.authorEmail);
+ r.currentAssignees = isReturned ? [{ name: r.authorName, email: r.authorEmail }] : [];
+ });
+
+ const inRoute = rows.filter((r) => IN_ROUTE_REQUEST_STATUSES.indexOf(r.status) >= 0);
+ if (inRoute.length === 0) return;
+ return traced('текущий исполнитель (REST)', () => hydrateCurrentAssigneesInner(context, types, inRoute));
+}
+
+async function hydrateCurrentAssigneesInner(
+ context: WebPartContext,
+ types: IRequestType[],
+ rows: IMyRequest[]
+): Promise {
+ const batches = await approvalPartitionsFor(context, types, rows);
+ const statusFilter = CURRENT_TURN_STEP_STATUSES.map((s) => `ApprovalStatus eq '${s}'`).join(' or ');
+
+ await Promise.all(
+ batches.map(async ({ approvalList, target, typeName, rows: partitionRows, ticketFilter }) => {
+ try {
+ const stepRows = (await approvalList.items
+ .filter(`${ticketFilter} and (${statusFilter})`)
+ .select('TicketId', `${STEP_APPROVER_FIELD}/Title`, `${STEP_APPROVER_FIELD}/EMail`)
+ .expand(STEP_APPROVER_FIELD)
+ .top(PAGE_ROUTE_ROW_LIMIT)()) as Array>;
+
+ const assigneesByTicket = new Map();
+ stepRows.forEach((r) => {
+ const ticketId = r.TicketId as number | undefined;
+ const user = r[STEP_APPROVER_FIELD] as { Title?: string; EMail?: string } | undefined;
+ const assignee: IAssignee = { name: (user?.Title || '').trim(), email: (user?.EMail || '').trim() };
+ if (!ticketId || (!assignee.name && !assignee.email)) return;
+ const assignees = assigneesByTicket.get(ticketId) ?? [];
+ // Один человек может стоять в двух строках одного шага (напр. сам и
+ // как делегат) — в ячейке он нужен один раз.
+ const isDuplicate = assignees.some((a) => a.name === assignee.name && a.email === assignee.email);
+ if (!isDuplicate) assignees.push(assignee);
+ assigneesByTicket.set(ticketId, assignees);
+ });
+
+ partitionRows.forEach((r) => {
+ r.currentAssignees = assigneesByTicket.get(r.id) ?? [];
+ });
+ } catch (e) {
+ console.error(
+ `[Текущие заявки] «${typeName}» (${target.listId}): не удалось прочитать текущего исполнителя:`,
+ (e as Error).message
+ );
+ }
+ })
+ );
+}
+
// «На согласовании»: список заявок уже отобран по строкам согласования
// (getPendingApprovalIndex — там же определяется, чья сейчас очередь,
// обычным REST). Здесь среди НИХ фильтруем по тексту — только через Search
diff --git a/src/webparts/requestPortal/features/myRequests/pageCache.test.ts b/src/webparts/requestPortal/features/myRequests/pageCache.test.ts
index d76b8b0..0373140 100644
--- a/src/webparts/requestPortal/features/myRequests/pageCache.test.ts
+++ b/src/webparts/requestPortal/features/myRequests/pageCache.test.ts
@@ -123,6 +123,13 @@ describe('pageSignature', () => {
expect(pageSignature(before)).not.toBe(pageSignature(after));
});
+ it('смена текущего исполнителя меняет подпись — он показан колонкой «Исполнитель»', () => {
+ // Заявка ушла на следующий шаг: статус тот же («На согласовании»), а держатель другой.
+ const before = page({ rows: [request({ currentAssignees: [{ name: 'Петров Пётр', email: 'petrov@tnsu.kz' }] })] });
+ const after = page({ rows: [request({ currentAssignees: [{ name: 'Сидорова Анна', email: 'sidorova@tnsu.kz' }] })] });
+ expect(pageSignature(before)).not.toBe(pageSignature(after));
+ });
+
it('пустая страница имеет стабильную подпись', () => {
expect(pageSignature({ rows: [], totalRows: 0 })).toBe(pageSignature({ rows: [], totalRows: 0 }));
});
diff --git a/src/webparts/requestPortal/features/myRequests/pageCache.ts b/src/webparts/requestPortal/features/myRequests/pageCache.ts
index f320f8b..f3f6da8 100644
--- a/src/webparts/requestPortal/features/myRequests/pageCache.ts
+++ b/src/webparts/requestPortal/features/myRequests/pageCache.ts
@@ -44,7 +44,17 @@ export function writePage(key: string, page: IPage): void {
export function pageSignature(page: IPage): string {
return `${page.totalRows}|${page.rows
.map((r) =>
- [r.requestTypeId, r.id, r.code, r.title, r.status, r.created, r.authorName, r.decisionComment ?? ''].join('~')
+ [
+ r.requestTypeId,
+ r.id,
+ r.code,
+ r.title,
+ r.status,
+ r.created,
+ r.authorName,
+ r.decisionComment ?? '',
+ (r.currentAssignees ?? []).map((a) => `${a.name}/${a.email}`).join(','),
+ ].join('~')
)
.join(';')}`;
}