From ea07e8d5abdfee8a73d6896af667ccfddcb88618 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A2=D0=B8=D0=BC=D1=83=D1=80=20=D0=93=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=B4=D0=B5=D1=86=D0=BA=D0=B8=D0=B9?= Date: Thu, 8 Oct 2026 16:42:07 +0500 Subject: [PATCH] =?UTF-8?q?feat:=20=D1=81=D1=82=D0=BE=D0=BB=D0=B1=D0=B5?= =?UTF-8?q?=D1=86=20=C2=AB=D0=98=D1=81=D0=BF=D0=BE=D0=BB=D0=BD=D0=B5=D0=BD?= =?UTF-8?q?=D0=BE=C2=BB=20=D0=B2=20=D1=82=D0=B0=D0=B1=D0=BB=D0=B8=D1=86?= =?UTF-8?q?=D0=B0=D1=85=20=D0=B7=D0=B0=D1=8F=D0=B2=D0=BE=D0=BA=20=E2=80=94?= =?UTF-8?q?=20=D0=BA=D1=82=D0=BE=20=D0=B8=20=D0=BA=D0=BE=D0=B3=D0=B4=D0=B0?= =?UTF-8?q?=20=D0=B2=D1=8B=D0=BF=D0=BE=D0=BB=D0=BD=D0=B8=D0=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Столбец «Исполнитель» показывает только текущего держателя, и было не видно, кто из исполнителей уже отметил «Выполнено». Теперь видно ФИО и время каждой отметки; столбец появляется, только если на странице есть такие. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../features/myRequests/RequestsTableView.tsx | 46 ++++++++++-- .../features/myRequests/models/IMyRequest.ts | 14 ++++ .../features/myRequests/myRequests.service.ts | 72 ++++++++++++++++++- .../features/myRequests/pageCache.test.ts | 9 +++ .../features/myRequests/pageCache.ts | 1 + 5 files changed, 136 insertions(+), 6 deletions(-) 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(';')}`;