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(';')}`; }