diff --git a/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx b/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx
index 1ce1fa1..6c3344b 100644
--- a/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx
+++ b/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx
@@ -14,6 +14,7 @@ import {
getOwnRequestsByStatus,
hydrateCurrentAssignees,
hydrateDecisionComments,
+ hydrateExecutedSteps,
hydratePendingAuthors,
searchOwnRequests,
} from './myRequests.service';
@@ -37,6 +38,7 @@ import { RequestHistoryDialog } from './RequestHistoryDialog';
import { SortableHeader } from './SortableHeader';
import { filterRows, hasActiveFilters, ISortState, SortKey, sortRows } from './requestsTableFilters';
import { useTableRefinement } from './useTableRefinement';
+import { formatDateTime } from '../../shared/ui/FieldChanges/formatDateTime';
import styles from './RequestsTableView.module.scss';
const PAGE_SIZE = 20;
@@ -139,6 +141,8 @@ interface ITableColumns {
status: boolean;
/** Колонка «Исполнитель» — у кого заявка сейчас (см. showsAssignee). */
assignee: boolean;
+ /** Колонка «Исполнено» — кто из исполнителей уже выполнил (см. showsExecuted). */
+ executed: boolean;
/** Колонка «Дата выхода» — только «Все заявки» по организации рабочего места. */
startDate?: boolean;
/** Заголовок колонки с комментарием; undefined — колонки нет. */
@@ -163,6 +167,17 @@ function showsAssignee(view: ListView, statusFilter: string): boolean {
return true;
}
+// «Исполнено» — в тех же разделах, что и «Исполнитель»: там заявку смотрят со
+// стороны, и важно, как далеко продвинулось исполнение. На «Инициированных» с
+// фильтром по статусу — только при статусах, где выполнившие уже бывают.
+const STATUSES_WITH_EXECUTED: string[] = [REQUEST_STATUS.execution, REQUEST_STATUS.closed];
+
+function showsExecuted(view: ListView, statusFilter: string): boolean {
+ if (ASSIGNEE_VIEWS.indexOf(view) < 0) return false;
+ if (view === 'own:created' && statusFilter) return STATUSES_WITH_EXECUTED.indexOf(statusFilter) >= 0;
+ return true;
+}
+
/** Дата выхода сотрудника есть только у заявки на организацию рабочего места, и показываем её во «Все заявки». */
function showsStartDate(view: ListView, type: IRequestType | undefined): boolean {
return view === 'all' && type?.pageUrl === WORKPLACE_SETUP_FORM_ID;
@@ -170,6 +185,7 @@ function showsStartDate(view: ListView, type: IRequestType | undefined): boolean
function columnsFor(view: ListView, statusFilter: string, startDate = false): ITableColumns {
const assignee = showsAssignee(view, statusFilter);
+ const executed = showsExecuted(view, statusFilter);
const decisionStatus = decisionStatusFor(view, statusFilter);
const commentLabel =
decisionStatus === REQUEST_STATUS.rejected
@@ -178,15 +194,15 @@ function columnsFor(view: ListView, statusFilter: string, startDate = false): IT
? 'Причина возврата'
: undefined;
- 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 };
+ if (view === 'approvals' || view === 'execution') return { author: true, status: false, assignee, executed };
+ if (view === 'rework') return { author: false, status: false, assignee, executed, commentLabel };
+ if (view === 'own:created') return { author: false, status: !statusFilter, assignee, executed, commentLabel };
// 'all' — заявки всех авторов вперемешку, поэтому нужны и автор, и статус.
- if (view === 'all') return { author: true, status: true, assignee, startDate };
+ if (view === 'all') return { author: true, status: true, assignee, executed, startDate };
// История решений: заявка чужая, поэтому нужен автор. Статус — только если он
// не предопределён самой вкладкой («Отклонённые мной» — это всегда «Отклонено»,
// а вот у согласованных/выполненных заявка могла уйти дальше по маршруту).
- return { author: true, status: !decisionStatus, assignee, commentLabel };
+ return { author: true, status: !decisionStatus, assignee, executed, commentLabel };
}
export interface IRequestsTableViewProps {
@@ -314,6 +330,25 @@ const Table: React.FC<{
: '—'
});
}
+ // Только если на странице есть хоть одна выполненная часть: у типов заявок
+ // без фазы исполнения (и на страницах, где до неё ещё не дошло) колонка
+ // вышла бы из одних прочерков.
+ if (cols.executed && rows.some((r) => (r.executedSteps ?? []).length > 0)) {
+ columns.push({
+ key: 'executed',
+ title: 'Исполнено',
+ width: '280px',
+ // Каждый выполнивший — отдельной строкой: «ФИО · дата время».
+ render: (r) =>
+ r.executedSteps && r.executedSteps.length > 0
+ ? r.executedSteps.map((s) => (
+
+ {[s.name || s.email, s.executedAt ? formatDateTime(s.executedAt) : ''].filter(Boolean).join(' · ')}
+
+ ))
+ : '—'
+ });
+ }
if (cols.commentLabel) {
columns.push({
key: 'comment',
@@ -614,6 +649,7 @@ export const RequestsTableView: React.FC = ({
await Promise.all([
decisionStatus ? hydrateDecisionComments(context, types, result.rows, decisionStatus) : undefined,
showsAssignee(view, statusFilter) ? hydrateCurrentAssignees(context, types, result.rows) : undefined,
+ showsExecuted(view, statusFilter) ? hydrateExecutedSteps(context, types, result.rows) : undefined,
showsStartDate(view, allRequestsType) ? hydrateStartDates(context, 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 38a740b..1e46ae0 100644
--- a/src/webparts/requestPortal/features/myRequests/models/IMyRequest.ts
+++ b/src/webparts/requestPortal/features/myRequests/models/IMyRequest.ts
@@ -4,6 +4,14 @@ export interface IAssignee {
email: string;
}
+/** Исполнитель, отметивший свой шаг «Выполнено» (колонка «Исполнено»). */
+export interface IExecutedStep {
+ name: string;
+ email: string;
+ /** Когда отметил (ISO-строка, колонка Date строки маршрута). */
+ executedAt: string;
+}
+
// Строка заявки пользователя для боковой панели «Мои заявки».
// Собирается из списка данных SharePoint конкретного типа заявки (см. myRequests.service.ts).
export interface IMyRequest {
@@ -76,6 +84,12 @@ export interface IMyRequest {
* hydrateCurrentAssignees).
*/
currentAssignees?: IAssignee[];
+ /**
+ * Исполнители, уже отметившие «Выполнено», по времени отметки (колонка
+ * «Исполнено»). Пустой массив — никто ещё не выполнил или у заявки нет фазы
+ * исполнения; undefined — не дочитывалось (см. hydrateExecutedSteps).
+ */
+ executedSteps?: IExecutedStep[];
/**
* Дата выхода сотрудника на работу (колонка StartDate) — только у заявок на
* организацию рабочего места, колонка «Дата выхода» во «Все заявки». Как и
diff --git a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts
index edbb0bf..0fbaa17 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 { IAssignee, IMyRequest } from './models/IMyRequest';
+import { IAssignee, IExecutedStep, IMyRequest } from './models/IMyRequest';
import { parseAuthorOwsUser } from './parseAuthorOwsUser';
import { IMyRequestsTypeDebug } from './models/IMyRequestsDebug';
import { traceLinkedTypes, traceResultRows, traceSearchHits } from './searchTrace';
@@ -1795,6 +1795,76 @@ async function hydrateCurrentAssigneesInner(
);
}
+// Статусы ЗАЯВКИ, при которых кто-то из исполнителей уже мог отметить «Выполнено».
+const WITH_EXECUTION_REQUEST_STATUSES: string[] = [REQUEST_STATUS.execution, REQUEST_STATUS.closed];
+
+/**
+ * Проставляет executedSteps — кто из исполнителей уже отметил «Выполнено» и
+ * когда, по времени отметки. Статус строки «Выполнено» бывает только в фазе
+ * исполнения, поэтому фильтра по статусу достаточно.
+ *
+ * Только для видимой страницы, как и hydrateCurrentAssignees. Мутирует
+ * переданные строки.
+ */
+export async function hydrateExecutedSteps(
+ context: WebPartContext,
+ types: IRequestType[],
+ rows: IMyRequest[]
+): Promise {
+ rows.forEach((r) => {
+ r.executedSteps = [];
+ });
+
+ const withExecution = rows.filter((r) => WITH_EXECUTION_REQUEST_STATUSES.indexOf(r.status) >= 0);
+ if (withExecution.length === 0) return;
+ return traced('выполнившие исполнители (REST)', () => hydrateExecutedStepsInner(context, types, withExecution));
+}
+
+async function hydrateExecutedStepsInner(
+ context: WebPartContext,
+ types: IRequestType[],
+ rows: IMyRequest[]
+): Promise {
+ const batches = await approvalPartitionsFor(context, types, rows);
+
+ await Promise.all(
+ batches.map(async ({ approvalList, target, typeName, rows: partitionRows, ticketFilter }) => {
+ try {
+ const stepRows = (await approvalList.items
+ .filter(`${ticketFilter} and ApprovalStatus eq '${REQUEST_STATUS.executed}'`)
+ .select('TicketId', 'Date', `${STEP_APPROVER_FIELD}/Title`, `${STEP_APPROVER_FIELD}/EMail`)
+ .expand(STEP_APPROVER_FIELD)
+ .orderBy('Date', true)
+ .top(PAGE_ROUTE_ROW_LIMIT)()) as Array>;
+
+ const stepsByTicket = 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 step: IExecutedStep = {
+ name: (user?.Title || '').trim(),
+ email: (user?.EMail || '').trim(),
+ executedAt: (r.Date as string) || '',
+ };
+ if (!ticketId || (!step.name && !step.email)) return;
+ const steps = stepsByTicket.get(ticketId) ?? [];
+ steps.push(step);
+ stepsByTicket.set(ticketId, steps);
+ });
+
+ partitionRows.forEach((r) => {
+ r.executedSteps = stepsByTicket.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 0373140..e154a29 100644
--- a/src/webparts/requestPortal/features/myRequests/pageCache.test.ts
+++ b/src/webparts/requestPortal/features/myRequests/pageCache.test.ts
@@ -130,6 +130,15 @@ describe('pageSignature', () => {
expect(pageSignature(before)).not.toBe(pageSignature(after));
});
+ it('новый выполнивший меняет подпись — он показан колонкой «Исполнено»', () => {
+ // Один из параллельных исполнителей отметил «Выполнено»: статус заявки тот же («Выполнение»).
+ const before = page({ rows: [request({ executedSteps: [] })] });
+ const after = page({
+ rows: [request({ executedSteps: [{ name: 'Петров Пётр', email: 'petrov@tnsu.kz', executedAt: '2026-10-08T05:00:00Z' }] })],
+ });
+ 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 37285dd..4618bf2 100644
--- a/src/webparts/requestPortal/features/myRequests/pageCache.ts
+++ b/src/webparts/requestPortal/features/myRequests/pageCache.ts
@@ -55,6 +55,7 @@ export function pageSignature(page: IPage): string {
r.decisionComment ?? '',
r.startDate ?? '',
(r.currentAssignees ?? []).map((a) => `${a.name}/${a.email}`).join(','),
+ (r.executedSteps ?? []).map((s) => `${s.name}/${s.email}/${s.executedAt}`).join(','),
].join('~')
)
.join(';')}`;