добавлен комментарий

This commit is contained in:
gorbenko.pavel
2026-08-10 17:52:25 +05:00
parent 926a514821
commit 7ef0ee6245
6 changed files with 86 additions and 49 deletions
@@ -8,8 +8,8 @@ import {
getLinkedTypes, getLinkedTypes,
getOwnRequests, getOwnRequests,
getOwnRequestsByStatus, getOwnRequestsByStatus,
hydrateDecisionComments,
hydratePendingAuthors, hydratePendingAuthors,
hydrateReturnComments,
searchOwnRequests, searchOwnRequests,
} from './myRequests.service'; } from './myRequests.service';
import { IMyRequest } from './models/IMyRequest'; 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 { interface ITableColumns {
author: boolean; author: boolean;
status: boolean; status: boolean;
returnComment: boolean; /** Заголовок колонки с комментарием; undefined — колонки нет. */
commentLabel?: string;
} }
function columnsFor(view: ListView, statusFilter: string): ITableColumns { function columnsFor(view: ListView, statusFilter: string): ITableColumns {
const returnedOnly = const decisionStatus = decisionStatusFor(view, statusFilter);
view === 'rework' || (view === 'own:created' && statusFilter === REQUEST_STATUS.returned); const commentLabel =
decisionStatus === REQUEST_STATUS.rejected
? 'Причина отклонения'
: decisionStatus === REQUEST_STATUS.returned
? 'Причина возврата'
: undefined;
if (view === 'approvals' || view === 'execution') { if (view === 'approvals' || view === 'execution') return { author: true, status: false };
return { author: true, status: false, returnComment: false }; if (view === 'rework') return { author: false, status: false, commentLabel };
} if (view === 'own:created') return { author: false, status: !statusFilter, commentLabel };
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: !decisionStatus, commentLabel };
return { author: true, status: true, returnComment: false };
} }
export interface IRequestsTableViewProps { export interface IRequestsTableViewProps {
@@ -188,7 +207,7 @@ const Table: React.FC<{
<th className={styles.colDate}>Дата</th> <th className={styles.colDate}>Дата</th>
{columns.author && <th className={styles.colAuthor}>Автор</th>} {columns.author && <th className={styles.colAuthor}>Автор</th>}
{columns.status && <th className={styles.colStatus}>Статус</th>} {columns.status && <th className={styles.colStatus}>Статус</th>}
{columns.returnComment && <th className={styles.colComment}>Причина возврата</th>} {columns.commentLabel && <th className={styles.colComment}>{columns.commentLabel}</th>}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -221,9 +240,9 @@ const Table: React.FC<{
{r.status ? <StatusBadge status={r.status} size="sm" /> : '—'} {r.status ? <StatusBadge status={r.status} size="sm" /> : '—'}
</td> </td>
)} )}
{columns.returnComment && ( {columns.commentLabel && (
<td className={styles.colComment}> <td className={styles.colComment}>
{r.returnComment || <span className={styles.commentEmpty}>Без комментария</span>} {r.decisionComment || <span className={styles.commentEmpty}>Без комментария</span>}
</td> </td>
)} )}
</tr> </tr>
@@ -341,18 +360,12 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
const q = debouncedQuery.trim(); const q = debouncedQuery.trim();
const fetchPage = async (): Promise<IPage> => { const fetchRows = async (): Promise<IPage> => {
// «Доработка» — тот же список, что «Инициированные», просто со статусом, // «Доработка» — тот же список, что «Инициированные», просто со статусом,
// зашитым в раздел, а не выбранным в фильтре. // зашитым в раздел, а не выбранным в фильтре.
if (view === 'rework' || (view === 'own:created' && statusFilter)) { if (view === 'rework' || (view === 'own:created' && statusFilter)) {
const status = view === 'rework' ? REQUEST_STATUS.returned : statusFilter; const status = view === 'rework' ? REQUEST_STATUS.returned : statusFilter;
const result = await getOwnRequestsByStatus(context, types, status, page, debouncedQuery); return getOwnRequestsByStatus(context, types, status, page, debouncedQuery);
// Причина возврата живёт в строке маршрута — дочитываем только для
// видимой страницы и только там, где колонка показывается.
if (status === REQUEST_STATUS.returned) {
await hydrateReturnComments(context, types, result.rows);
}
return result;
} }
if (view === 'own:created') { if (view === 'own:created') {
@@ -378,6 +391,19 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
return { rows: pageRows, totalRows: filtered.length }; return { rows: pageRows, totalRows: filtered.length };
}; };
// Комментарий к решению дочитываем ОДНИМ местом на все ветки: «Доработка» и
// «Отклонённые мной» приходят разными путями (по автору и по строкам
// маршрута соответственно), а колонка у них одинаковая по смыслу. И только
// для видимой страницы — как и автор в hydratePendingAuthors.
const fetchPage = async (): Promise<IPage> => {
const result = await fetchRows();
const decisionStatus = decisionStatusFor(view, statusFilter);
if (decisionStatus) {
await hydrateDecisionComments(context, types, result.rows, decisionStatus);
}
return result;
};
const load = async (): Promise<void> => { const load = async (): Promise<void> => {
try { try {
const fresh = await fetchPage(); const fresh = await fetchPage();
@@ -23,9 +23,10 @@ export interface IMyRequest {
*/ */
myStepStatus?: string; myStepStatus?: string;
/** /**
* Комментарий согласующего, вернувшего заявку на доработку. Живёт не в списке * Комментарий согласующего к решению — причина возврата на доработку или
* данных, а в строке МАРШРУТА, поэтому дочитывается отдельно и только там, * отклонения (какое именно решение, задаёт вызывающая сторона). Живёт не в
* где нужен (см. hydrateReturnComments). * списке данных, а в строке МАРШРУТА, поэтому дочитывается отдельно и только
* там, где показывается (см. hydrateDecisionComments).
*/ */
returnComment?: string; decisionComment?: string;
} }
@@ -801,22 +801,29 @@ async function approvalListFor(
// Сколько строк маршрута максимум забираем за раз при дочитывании комментариев: // Сколько строк маршрута максимум забираем за раз при дочитывании комментариев:
// на странице ≤20 заявок, у каждой обычно единицы шагов — запас многократный. // на странице ≤20 заявок, у каждой обычно единицы шагов — запас многократный.
const RETURN_COMMENT_ROW_LIMIT = 2000; const DECISION_COMMENT_ROW_LIMIT = 2000;
/** /**
* Проставляет returnComment — комментарий согласующего, вернувшего заявку на * Проставляет decisionComment — комментарий согласующего к его решению по
* доработку. Причина возврата хранится в СТРОКЕ МАРШРУТА (колонка Comment * заявке: причину возврата на доработку либо отклонения (что именно искать,
* списка «— согласование»), а не в самой заявке, поэтому её нельзя получить * задаёт decisionStatus). Комментарий хранится в СТРОКЕ МАРШРУТА (колонка
* тем же запросом, что и список: нужен отдельный проход по спискам согласования. * Comment списка «— согласование»), а не в самой заявке, поэтому его нельзя
* получить тем же запросом, что и список: нужен отдельный проход по спискам
* согласования.
*
* Одного статуса достаточно, чтобы найти нужную строку, и для «своих
* возвращённых» (вернул кто-то другой), и для «отклонённых мной»: и возврат, и
* отклонение останавливают маршрут, поэтому такая строка по заявке одна.
* *
* Вызывается ТОЛЬКО там, где причина показывается (см. RequestsTableView) — * Вызывается ТОЛЬКО там, где причина показывается (см. RequestsTableView) —
* счётчику в меню, например, она не нужна, и лишние запросы ему ни к чему. * счётчику в меню, например, она не нужна, и лишние запросы ему ни к чему.
* Мутирует переданные строки — как и hydrateAuthors рядом. * Мутирует переданные строки — как и hydrateAuthors рядом.
*/ */
export async function hydrateReturnComments( export async function hydrateDecisionComments(
context: WebPartContext, context: WebPartContext,
types: IRequestType[], types: IRequestType[],
rows: IMyRequest[] rows: IMyRequest[],
decisionStatus: string
): Promise<void> { ): Promise<void> {
if (rows.length === 0) return; if (rows.length === 0) return;
@@ -844,27 +851,28 @@ export async function hydrateReturnComments(
const stepRows = (await approvalList.items const stepRows = (await approvalList.items
.filter(typeRows.map((r) => `TicketId eq ${r.id}`).join(' or ')) .filter(typeRows.map((r) => `TicketId eq ${r.id}`).join(' or '))
.select('TicketId', 'ApprovalStatus', 'Comment') .select('TicketId', 'ApprovalStatus', 'Comment')
.top(RETURN_COMMENT_ROW_LIMIT)()) as Array<Record<string, unknown>>; .top(DECISION_COMMENT_ROW_LIMIT)()) as Array<Record<string, unknown>>;
const commentByTicket = new Map<number, string>(); const commentByTicket = new Map<number, string>();
stepRows.forEach((r) => { stepRows.forEach((r) => {
if (r.ApprovalStatus !== REQUEST_STATUS.returned) return; if (r.ApprovalStatus !== decisionStatus) return;
const ticketId = r.TicketId as number | undefined; const ticketId = r.TicketId as number | undefined;
const comment = ((r.Comment as string) || '').trim(); const comment = ((r.Comment as string) || '').trim();
// Возврат по заявке один (при переотправке маршрут переписывается // Такая строка по заявке одна (и возврат, и отклонение останавливают
// целиком, см. writeRoute) — но если строк вдруг несколько, берём // маршрут, а при переотправке он переписывается целиком — см.
// первую непустую, а не последнюю пустую. // writeRoute), но если их вдруг несколько, берём первую НЕПУСТУЮ,
// а не последнюю пустую.
if (ticketId && comment && !commentByTicket.has(ticketId)) { if (ticketId && comment && !commentByTicket.has(ticketId)) {
commentByTicket.set(ticketId, comment); commentByTicket.set(ticketId, comment);
} }
}); });
typeRows.forEach((r) => { typeRows.forEach((r) => {
r.returnComment = commentByTicket.get(r.id); r.decisionComment = commentByTicket.get(r.id);
}); });
} catch (e) { } catch (e) {
console.error( console.error(
`[Текущие заявки] «${type.name}»: не удалось прочитать причину возврата:`, `[Текущие заявки] «${type.name}»: не удалось прочитать комментарий к решению:`,
(e as Error).message (e as Error).message
); );
} }
@@ -99,15 +99,15 @@ describe('pageSignature', () => {
expect(pageSignature(before)).toBe(pageSignature(after)); expect(pageSignature(before)).toBe(pageSignature(after));
}); });
it('изменение причины возврата меняет подпись — она видна в таблице «Доработка»', () => { it('изменение комментария к решению меняет подпись — он показан колонкой «Причина возврата/отклонения»', () => {
const before = page({ rows: [request({ returnComment: 'Исправьте должность' })] }); const before = page({ rows: [request({ decisionComment: 'Исправьте должность' })] });
const after = page({ rows: [request({ returnComment: 'Приложите скан' })] }); const after = page({ rows: [request({ decisionComment: 'Приложите скан' })] });
expect(pageSignature(before)).not.toBe(pageSignature(after)); expect(pageSignature(before)).not.toBe(pageSignature(after));
}); });
it('появление причины возврата у строки без неё тоже меняет подпись', () => { it('появление комментария к решению у строки без него тоже меняет подпись', () => {
const before = page({ rows: [request()] }); const before = page({ rows: [request()] });
const after = page({ rows: [request({ returnComment: 'Исправьте должность' })] }); const after = page({ rows: [request({ decisionComment: 'Исправьте должность' })] });
expect(pageSignature(before)).not.toBe(pageSignature(after)); expect(pageSignature(before)).not.toBe(pageSignature(after));
}); });
@@ -39,7 +39,7 @@ export function writePage(key: string, page: IPage): void {
export function pageSignature(page: IPage): string { export function pageSignature(page: IPage): string {
return `${page.totalRows}|${page.rows return `${page.totalRows}|${page.rows
.map((r) => .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(';')}`; .join(';')}`;
} }
@@ -60,6 +60,8 @@ export interface IRoute {
ownStatus?: string; ownStatus?: string;
/** Номер страницы таблицы (1-based). 1 — значение по умолчанию, в адрес не пишется. */ /** Номер страницы таблицы (1-based). 1 — значение по умолчанию, в адрес не пишется. */
page?: number; page?: number;
/** Текст поиска по таблице. Пустой — значение по умолчанию, в адрес не пишется. */
query?: string;
} }
const ROUTE_CHANGE_EVENT = 'rp-route-change'; const ROUTE_CHANGE_EVENT = 'rp-route-change';