From 7ef0ee62457da834d1ed2bc827ccddac57bee81b Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Mon, 10 Aug 2026 17:52:25 +0500 Subject: [PATCH] =?UTF-8?q?=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=20=D0=BA=D0=BE=D0=BC=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=80?= =?UTF-8?q?=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/myRequests/RequestsTableView.tsx | 76 +++++++++++++------ .../features/myRequests/models/IMyRequest.ts | 9 ++- .../features/myRequests/myRequests.service.ts | 36 +++++---- .../features/myRequests/pageCache.test.ts | 10 +-- .../features/myRequests/pageCache.ts | 2 +- src/webparts/requestPortal/shared/routing.ts | 2 + 6 files changed, 86 insertions(+), 49 deletions(-) diff --git a/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx b/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx index 16b439d..1fdbe95 100644 --- a/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx +++ b/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx @@ -8,8 +8,8 @@ import { getLinkedTypes, getOwnRequests, getOwnRequestsByStatus, + hydrateDecisionComments, hydratePendingAuthors, - hydrateReturnComments, searchOwnRequests, } from './myRequests.service'; import { IMyRequest } from './models/IMyRequest'; @@ -117,26 +117,45 @@ function statusOptionClassName(status: string): string { // его в фильтре, и колонка вырождается в тот же столбец одинаковых плашек, // поэтому она зависит не от одного вида, а ещё и от фильтра. // -// «Причина возврата» — там, где показаны ТОЛЬКО возвращённые заявки: для них -// это главное, ради чего в список и заходят (что именно просят исправить). +// Статус решения, ОБЩИЙ для всех строк раздела, — если он есть, показывать его +// колонкой незачем (столбец одинаковых плашек), зато полезен комментарий +// согласующего: почему вернули или почему отклонили. Именно за этим в такие +// списки и заходят. +function decisionStatusFor(view: ListView, statusFilter: string): string | undefined { + if (view === 'rework') return REQUEST_STATUS.returned; + if (view === 'own:rejected') return REQUEST_STATUS.rejected; + if ( + view === 'own:created' && + (statusFilter === REQUEST_STATUS.returned || statusFilter === REQUEST_STATUS.rejected) + ) { + return statusFilter; + } + return undefined; +} + interface ITableColumns { author: boolean; status: boolean; - returnComment: boolean; + /** Заголовок колонки с комментарием; undefined — колонки нет. */ + commentLabel?: string; } function columnsFor(view: ListView, statusFilter: string): ITableColumns { - const returnedOnly = - view === 'rework' || (view === 'own:created' && statusFilter === REQUEST_STATUS.returned); + const decisionStatus = decisionStatusFor(view, statusFilter); + const commentLabel = + decisionStatus === REQUEST_STATUS.rejected + ? 'Причина отклонения' + : decisionStatus === REQUEST_STATUS.returned + ? 'Причина возврата' + : undefined; - if (view === 'approvals' || view === 'execution') { - return { author: true, status: false, returnComment: false }; - } - if (view === 'rework') return { author: false, status: false, returnComment: true }; - if (view === 'own:created') { - return { author: false, status: !statusFilter, returnComment: returnedOnly }; - } - return { author: true, status: true, returnComment: false }; + 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 }; + // История решений: заявка чужая, поэтому нужен автор. Статус — только если он + // не предопределён самой вкладкой («Отклонённые мной» — это всегда «Отклонено», + // а вот у согласованных/выполненных заявка могла уйти дальше по маршруту). + return { author: true, status: !decisionStatus, commentLabel }; } export interface IRequestsTableViewProps { @@ -188,7 +207,7 @@ const Table: React.FC<{ Дата {columns.author && Автор} {columns.status && Статус} - {columns.returnComment && Причина возврата} + {columns.commentLabel && {columns.commentLabel}} @@ -221,9 +240,9 @@ const Table: React.FC<{ {r.status ? : '—'} )} - {columns.returnComment && ( + {columns.commentLabel && ( - {r.returnComment || Без комментария} + {r.decisionComment || Без комментария} )} @@ -341,18 +360,12 @@ export const RequestsTableView: React.FC = ({ const q = debouncedQuery.trim(); - const fetchPage = async (): Promise => { + const fetchRows = async (): Promise => { // «Доработка» — тот же список, что «Инициированные», просто со статусом, // зашитым в раздел, а не выбранным в фильтре. if (view === 'rework' || (view === 'own:created' && statusFilter)) { const status = view === 'rework' ? REQUEST_STATUS.returned : statusFilter; - const result = await getOwnRequestsByStatus(context, types, status, page, debouncedQuery); - // Причина возврата живёт в строке маршрута — дочитываем только для - // видимой страницы и только там, где колонка показывается. - if (status === REQUEST_STATUS.returned) { - await hydrateReturnComments(context, types, result.rows); - } - return result; + return getOwnRequestsByStatus(context, types, status, page, debouncedQuery); } if (view === 'own:created') { @@ -378,6 +391,19 @@ export const RequestsTableView: React.FC = ({ return { rows: pageRows, totalRows: filtered.length }; }; + // Комментарий к решению дочитываем ОДНИМ местом на все ветки: «Доработка» и + // «Отклонённые мной» приходят разными путями (по автору и по строкам + // маршрута соответственно), а колонка у них одинаковая по смыслу. И только + // для видимой страницы — как и автор в hydratePendingAuthors. + const fetchPage = async (): Promise => { + const result = await fetchRows(); + const decisionStatus = decisionStatusFor(view, statusFilter); + if (decisionStatus) { + await hydrateDecisionComments(context, types, result.rows, decisionStatus); + } + return result; + }; + const load = async (): Promise => { try { const fresh = await fetchPage(); diff --git a/src/webparts/requestPortal/features/myRequests/models/IMyRequest.ts b/src/webparts/requestPortal/features/myRequests/models/IMyRequest.ts index b07f51d..d53c5ba 100644 --- a/src/webparts/requestPortal/features/myRequests/models/IMyRequest.ts +++ b/src/webparts/requestPortal/features/myRequests/models/IMyRequest.ts @@ -23,9 +23,10 @@ export interface IMyRequest { */ myStepStatus?: string; /** - * Комментарий согласующего, вернувшего заявку на доработку. Живёт не в списке - * данных, а в строке МАРШРУТА, поэтому дочитывается отдельно и только там, - * где нужен (см. hydrateReturnComments). + * Комментарий согласующего к решению — причина возврата на доработку или + * отклонения (какое именно решение, задаёт вызывающая сторона). Живёт не в + * списке данных, а в строке МАРШРУТА, поэтому дочитывается отдельно и только + * там, где показывается (см. hydrateDecisionComments). */ - returnComment?: string; + decisionComment?: string; } diff --git a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts index 09ebc51..fa24ad2 100644 --- a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts +++ b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts @@ -801,22 +801,29 @@ async function approvalListFor( // Сколько строк маршрута максимум забираем за раз при дочитывании комментариев: // на странице ≤20 заявок, у каждой обычно единицы шагов — запас многократный. -const RETURN_COMMENT_ROW_LIMIT = 2000; +const DECISION_COMMENT_ROW_LIMIT = 2000; /** - * Проставляет returnComment — комментарий согласующего, вернувшего заявку на - * доработку. Причина возврата хранится в СТРОКЕ МАРШРУТА (колонка Comment - * списка «— согласование»), а не в самой заявке, поэтому её нельзя получить - * тем же запросом, что и список: нужен отдельный проход по спискам согласования. + * Проставляет decisionComment — комментарий согласующего к его решению по + * заявке: причину возврата на доработку либо отклонения (что именно искать, + * задаёт decisionStatus). Комментарий хранится в СТРОКЕ МАРШРУТА (колонка + * Comment списка «— согласование»), а не в самой заявке, поэтому его нельзя + * получить тем же запросом, что и список: нужен отдельный проход по спискам + * согласования. + * + * Одного статуса достаточно, чтобы найти нужную строку, и для «своих + * возвращённых» (вернул кто-то другой), и для «отклонённых мной»: и возврат, и + * отклонение останавливают маршрут, поэтому такая строка по заявке одна. * * Вызывается ТОЛЬКО там, где причина показывается (см. RequestsTableView) — * счётчику в меню, например, она не нужна, и лишние запросы ему ни к чему. * Мутирует переданные строки — как и hydrateAuthors рядом. */ -export async function hydrateReturnComments( +export async function hydrateDecisionComments( context: WebPartContext, types: IRequestType[], - rows: IMyRequest[] + rows: IMyRequest[], + decisionStatus: string ): Promise { if (rows.length === 0) return; @@ -844,27 +851,28 @@ export async function hydrateReturnComments( const stepRows = (await approvalList.items .filter(typeRows.map((r) => `TicketId eq ${r.id}`).join(' or ')) .select('TicketId', 'ApprovalStatus', 'Comment') - .top(RETURN_COMMENT_ROW_LIMIT)()) as Array>; + .top(DECISION_COMMENT_ROW_LIMIT)()) as Array>; const commentByTicket = new Map(); stepRows.forEach((r) => { - if (r.ApprovalStatus !== REQUEST_STATUS.returned) return; + if (r.ApprovalStatus !== decisionStatus) return; const ticketId = r.TicketId as number | undefined; const comment = ((r.Comment as string) || '').trim(); - // Возврат по заявке один (при переотправке маршрут переписывается - // целиком, см. writeRoute) — но если строк вдруг несколько, берём - // первую непустую, а не последнюю пустую. + // Такая строка по заявке одна (и возврат, и отклонение останавливают + // маршрут, а при переотправке он переписывается целиком — см. + // writeRoute), но если их вдруг несколько, берём первую НЕПУСТУЮ, + // а не последнюю пустую. if (ticketId && comment && !commentByTicket.has(ticketId)) { commentByTicket.set(ticketId, comment); } }); typeRows.forEach((r) => { - r.returnComment = commentByTicket.get(r.id); + r.decisionComment = commentByTicket.get(r.id); }); } catch (e) { console.error( - `[Текущие заявки] «${type.name}»: не удалось прочитать причину возврата:`, + `[Текущие заявки] «${type.name}»: не удалось прочитать комментарий к решению:`, (e as Error).message ); } diff --git a/src/webparts/requestPortal/features/myRequests/pageCache.test.ts b/src/webparts/requestPortal/features/myRequests/pageCache.test.ts index 153ad1c..26c4421 100644 --- a/src/webparts/requestPortal/features/myRequests/pageCache.test.ts +++ b/src/webparts/requestPortal/features/myRequests/pageCache.test.ts @@ -99,15 +99,15 @@ describe('pageSignature', () => { expect(pageSignature(before)).toBe(pageSignature(after)); }); - it('изменение причины возврата меняет подпись — она видна в таблице «Доработка»', () => { - const before = page({ rows: [request({ returnComment: 'Исправьте должность' })] }); - const after = page({ rows: [request({ returnComment: 'Приложите скан' })] }); + it('изменение комментария к решению меняет подпись — он показан колонкой «Причина возврата/отклонения»', () => { + const before = page({ rows: [request({ decisionComment: 'Исправьте должность' })] }); + const after = page({ rows: [request({ decisionComment: 'Приложите скан' })] }); expect(pageSignature(before)).not.toBe(pageSignature(after)); }); - it('появление причины возврата у строки без неё тоже меняет подпись', () => { + it('появление комментария к решению у строки без него тоже меняет подпись', () => { const before = page({ rows: [request()] }); - const after = page({ rows: [request({ returnComment: 'Исправьте должность' })] }); + const after = page({ rows: [request({ decisionComment: 'Исправьте должность' })] }); expect(pageSignature(before)).not.toBe(pageSignature(after)); }); diff --git a/src/webparts/requestPortal/features/myRequests/pageCache.ts b/src/webparts/requestPortal/features/myRequests/pageCache.ts index 52d1569..7d0fbb9 100644 --- a/src/webparts/requestPortal/features/myRequests/pageCache.ts +++ b/src/webparts/requestPortal/features/myRequests/pageCache.ts @@ -39,7 +39,7 @@ 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.title, r.status, r.created, r.authorName, r.returnComment ?? ''].join('~') + [r.requestTypeId, r.id, r.title, r.status, r.created, r.authorName, r.decisionComment ?? ''].join('~') ) .join(';')}`; } diff --git a/src/webparts/requestPortal/shared/routing.ts b/src/webparts/requestPortal/shared/routing.ts index 6496c66..6793eac 100644 --- a/src/webparts/requestPortal/shared/routing.ts +++ b/src/webparts/requestPortal/shared/routing.ts @@ -60,6 +60,8 @@ export interface IRoute { ownStatus?: string; /** Номер страницы таблицы (1-based). 1 — значение по умолчанию, в адрес не пишется. */ page?: number; + /** Текст поиска по таблице. Пустой — значение по умолчанию, в адрес не пишется. */ + query?: string; } const ROUTE_CHANGE_EVENT = 'rp-route-change';