diff --git a/src/webparts/requestPortal/features/myRequests/RequestsTableView.module.scss b/src/webparts/requestPortal/features/myRequests/RequestsTableView.module.scss index 07b4978..411ad0e 100644 --- a/src/webparts/requestPortal/features/myRequests/RequestsTableView.module.scss +++ b/src/webparts/requestPortal/features/myRequests/RequestsTableView.module.scss @@ -198,6 +198,18 @@ width: 190px; } +// Причина возврата — свободный текст согласующего, поэтому переносим по словам +// (в т.ч. длинное слово без пробелов, иначе оно растянет таблицу). +.colComment { + white-space: normal; + overflow-wrap: anywhere; + color: $gray-700; +} + +.commentEmpty { + color: $gray-400; +} + // Раскраска статуса — теперь только в shared/approval/ui/StatusBadge (см. // использование ниже с size="sm"). Раньше здесь была своя копия этих цветов — // она разошлась с эталоном (На согласовании/Возвращена на доработку оказались diff --git a/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx b/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx index 37c67e5..16b439d 100644 --- a/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx +++ b/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx @@ -9,6 +9,7 @@ import { getOwnRequests, getOwnRequestsByStatus, hydratePendingAuthors, + hydrateReturnComments, searchOwnRequests, } from './myRequests.service'; import { IMyRequest } from './models/IMyRequest'; @@ -115,11 +116,27 @@ function statusOptionClassName(status: string): string { // 'own:created' — статус нужен, только пока показаны ВСЕ заявки. Стоит выбрать // его в фильтре, и колонка вырождается в тот же столбец одинаковых плашек, // поэтому она зависит не от одного вида, а ещё и от фильтра. -function columnsFor(view: ListView, statusFilter: string): { author: boolean; status: boolean } { - if (view === 'approvals' || view === 'execution') return { author: true, status: false }; - if (view === 'rework') return { author: false, status: false }; - if (view === 'own:created') return { author: false, status: !statusFilter }; - return { author: true, status: true }; +// +// «Причина возврата» — там, где показаны ТОЛЬКО возвращённые заявки: для них +// это главное, ради чего в список и заходят (что именно просят исправить). +interface ITableColumns { + author: boolean; + status: boolean; + returnComment: boolean; +} + +function columnsFor(view: ListView, statusFilter: string): ITableColumns { + const returnedOnly = + view === 'rework' || (view === 'own:created' && statusFilter === REQUEST_STATUS.returned); + + 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 }; } export interface IRequestsTableViewProps { @@ -171,6 +188,7 @@ const Table: React.FC<{ Дата {columns.author && Автор} {columns.status && Статус} + {columns.returnComment && Причина возврата} @@ -203,6 +221,11 @@ const Table: React.FC<{ {r.status ? : '—'} )} + {columns.returnComment && ( + + {r.returnComment || Без комментария} + + )} ))} @@ -323,7 +346,13 @@ export const RequestsTableView: React.FC = ({ // зашитым в раздел, а не выбранным в фильтре. if (view === 'rework' || (view === 'own:created' && statusFilter)) { const status = view === 'rework' ? REQUEST_STATUS.returned : statusFilter; - return getOwnRequestsByStatus(context, types, status, page, debouncedQuery); + const result = await getOwnRequestsByStatus(context, types, status, page, debouncedQuery); + // Причина возврата живёт в строке маршрута — дочитываем только для + // видимой страницы и только там, где колонка показывается. + if (status === REQUEST_STATUS.returned) { + await hydrateReturnComments(context, types, result.rows); + } + return result; } if (view === 'own:created') { diff --git a/src/webparts/requestPortal/features/myRequests/models/IMyRequest.ts b/src/webparts/requestPortal/features/myRequests/models/IMyRequest.ts index 4630ccc..b07f51d 100644 --- a/src/webparts/requestPortal/features/myRequests/models/IMyRequest.ts +++ b/src/webparts/requestPortal/features/myRequests/models/IMyRequest.ts @@ -22,4 +22,10 @@ export interface IMyRequest { * не показывается — поэтому и не входит в pageSignature. */ myStepStatus?: string; + /** + * Комментарий согласующего, вернувшего заявку на доработку. Живёт не в списке + * данных, а в строке МАРШРУТА, поэтому дочитывается отдельно и только там, + * где нужен (см. hydrateReturnComments). + */ + returnComment?: string; } diff --git a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts index 47d4edf..09ebc51 100644 --- a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts +++ b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts @@ -785,6 +785,93 @@ export async function getOwnRequestsByStatus( }; } +// Список согласования по типу заявки: Title списка данных → Title списка +// согласования по конвенции имён (та же логика, что в indexApprovalsForType). +// undefined — список заведён не по конвенции, маршрута для него не найти. +async function approvalListFor( + context: WebPartContext, + type: IRequestType +): Promise { + const sp = spForSite(type.siteUrl as string, context); + const info = (await sp.web.getList(type.listId as string).select('Title')()) as { Title?: string }; + const dataTitle = info.Title || ''; + if (!DATA_LIST_SUFFIX.test(dataTitle)) return undefined; + return sp.web.lists.getByTitle(`${dataTitle.replace(DATA_LIST_SUFFIX, '')} — согласование`); +} + +// Сколько строк маршрута максимум забираем за раз при дочитывании комментариев: +// на странице ≤20 заявок, у каждой обычно единицы шагов — запас многократный. +const RETURN_COMMENT_ROW_LIMIT = 2000; + +/** + * Проставляет returnComment — комментарий согласующего, вернувшего заявку на + * доработку. Причина возврата хранится в СТРОКЕ МАРШРУТА (колонка Comment + * списка «— согласование»), а не в самой заявке, поэтому её нельзя получить + * тем же запросом, что и список: нужен отдельный проход по спискам согласования. + * + * Вызывается ТОЛЬКО там, где причина показывается (см. RequestsTableView) — + * счётчику в меню, например, она не нужна, и лишние запросы ему ни к чему. + * Мутирует переданные строки — как и hydrateAuthors рядом. + */ +export async function hydrateReturnComments( + context: WebPartContext, + types: IRequestType[], + rows: IMyRequest[] +): Promise { + if (rows.length === 0) return; + + const typeById = new Map(); + types.forEach((t) => typeById.set(t.id, t)); + + const rowsByType = new Map(); + rows.forEach((r) => { + const list = rowsByType.get(r.requestTypeId) || []; + list.push(r); + rowsByType.set(r.requestTypeId, list); + }); + + await Promise.all( + Array.from(rowsByType.entries()).map(async ([requestTypeId, typeRows]) => { + const type = typeById.get(requestTypeId); + if (!type) return; + try { + const approvalList = await approvalListFor(context, type); + if (!approvalList) return; + + // Фильтруем только по TicketId (числовое поле), статус сравниваем в JS: + // кириллица в $filter по Choice-колонке молча возвращает 0 строк — + // ровно та же причина, что и в indexApprovalsForType. + 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>; + + const commentByTicket = new Map(); + stepRows.forEach((r) => { + if (r.ApprovalStatus !== REQUEST_STATUS.returned) return; + const ticketId = r.TicketId as number | undefined; + const comment = ((r.Comment as string) || '').trim(); + // Возврат по заявке один (при переотправке маршрут переписывается + // целиком, см. writeRoute) — но если строк вдруг несколько, берём + // первую непустую, а не последнюю пустую. + if (ticketId && comment && !commentByTicket.has(ticketId)) { + commentByTicket.set(ticketId, comment); + } + }); + + typeRows.forEach((r) => { + r.returnComment = commentByTicket.get(r.id); + }); + } catch (e) { + console.error( + `[Текущие заявки] «${type.name}»: не удалось прочитать причину возврата:`, + (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 729e595..153ad1c 100644 --- a/src/webparts/requestPortal/features/myRequests/pageCache.test.ts +++ b/src/webparts/requestPortal/features/myRequests/pageCache.test.ts @@ -99,6 +99,18 @@ describe('pageSignature', () => { expect(pageSignature(before)).toBe(pageSignature(after)); }); + it('изменение причины возврата меняет подпись — она видна в таблице «Доработка»', () => { + const before = page({ rows: [request({ returnComment: 'Исправьте должность' })] }); + const after = page({ rows: [request({ returnComment: 'Приложите скан' })] }); + expect(pageSignature(before)).not.toBe(pageSignature(after)); + }); + + it('появление причины возврата у строки без неё тоже меняет подпись', () => { + const before = page({ rows: [request()] }); + const after = page({ rows: [request({ returnComment: 'Исправьте должность' })] }); + 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 8159322..52d1569 100644 --- a/src/webparts/requestPortal/features/myRequests/pageCache.ts +++ b/src/webparts/requestPortal/features/myRequests/pageCache.ts @@ -38,6 +38,8 @@ 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].join('~')) + .map((r) => + [r.requestTypeId, r.id, r.title, r.status, r.created, r.authorName, r.returnComment ?? ''].join('~') + ) .join(';')}`; }