feat: фильтры и сортировка в таблицах заявок
Tests / frontend-test (push) Successful in 1m16s

Во всех разделах со списками заявок (На согласовании, На исполнении,
Доработка, Мои заявки, Все заявки):
- фильтры по типу, статусу и периоду даты создания, кнопка «Сбросить»;
- сортировка по клику на заголовок: номер (по числам), тип, дата, статус.

Без фильтров загрузка прежняя, постраничная. При активном фильтре или
сортировке загружается полный список раздела и обрабатывается в памяти,
иначе отсортировалась бы только текущая страница.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Тимур Городецкий
2026-09-28 14:45:02 +05:00
parent fa27e8d918
commit d749bfae26
10 changed files with 814 additions and 108 deletions
@@ -414,6 +414,22 @@ export async function getApprovalQueue(
kind: ApprovalQueueKind,
page: number = 1
): Promise<IPendingApprovalPage> {
const { types, rows, debug } = await getApprovalQueueRows(context, kind);
const pageRows = rows.slice((page - 1) * PENDING_PAGE_SIZE, page * PENDING_PAGE_SIZE);
await hydrateAuthors(context, types, pageRows);
return { rows: pageRows, totalRows: rows.length, debug };
}
// ВСЯ очередь целиком, без нарезки на страницы, — для фильтров и сортировки
// таблицы (см. requestsTableFilters.ts): отобрать и упорядочить честно можно
// только полный набор, а не одну страницу. Автор не дочитывается — его
// дочитывает вызывающая сторона для видимой страницы (hydratePendingAuthors).
export async function getApprovalQueueRows(
context: WebPartContext,
kind: ApprovalQueueKind
): Promise<{ rows: IMyRequest[]; types: IRequestType[]; debug: IMyRequestsTypeDebug[] }> {
// Очереди действий («На согласовании» и «На исполнении») собираются ОДНОЙ
// выборкой по обоим статусам, а разносятся уже по myStepStatus — ровно так же,
// как это делают счётчики меню (см. getNavCountsInner). Смысл: это тот же
@@ -433,10 +449,7 @@ export async function getApprovalQueue(
// finalizeApprovalTarget перечитывает статус REST-ом.
const rows = shared ? index.filter((r) => r.myStepStatus === QUEUE_STATUSES[kind][0]) : index;
const pageRows = rows.slice((page - 1) * PENDING_PAGE_SIZE, page * PENDING_PAGE_SIZE);
await hydrateAuthors(context, types, pageRows);
return { rows: pageRows, totalRows: rows.length, debug };
return { rows, types, debug };
}
// С текстовым поиском: полный индекс (без автора) — фильтрует его вызывающая
@@ -1409,6 +1422,18 @@ export async function getAllRequestsForType(
page: number,
query: string
): Promise<IOwnRequestsPage> {
const rows = await getAllRequestsRowsForType(context, type, query);
return { rows: rows.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE), totalRows: rows.length };
}
// Весь набор «Все заявки» одного типа, без нарезки на страницы — его же режет
// getAllRequestsForType, и его же фильтрует/сортирует таблица (см.
// requestsTableFilters.ts).
export async function getAllRequestsRowsForType(
context: WebPartContext,
type: IRequestType,
query: string
): Promise<IMyRequest[]> {
const sp = spForSite(type.siteUrl as string, context);
const clauses = [pathClauseForTypes([type])];
const text = freeTextClause(query);
@@ -1421,9 +1446,7 @@ export async function getAllRequestsForType(
// kind: 'other' — заявка не обязательно текущего пользователя, открывается
// только на просмотр (см. IMyRequest.kind, openRequest в RequestPortal.tsx).
const rows = hitsToEntries({ PrimarySearchResults: hits }, [type]).map((e) => toMyRequestFromSearch(e, 'other'));
return { rows: rows.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE), totalRows: rows.length };
return hitsToEntries({ PrimarySearchResults: hits }, [type]).map((e) => toMyRequestFromSearch(e, 'other'));
}
// Свои заявки с конкретным статусом ЗАЯВКИ (не строки маршрута) — «Доработка»
@@ -1516,6 +1539,45 @@ async function ownRequestsByStatus(
return raceThenMerge(searchPromise, freshPromise, onPartial);
}
/**
* ВСЕ свои заявки (любого статуса), без нарезки на страницы — для фильтров и
* сортировки таблицы (см. requestsTableFilters.ts). Отбор по статусу делает
* вызывающая сторона в памяти.
*
* Search находит весь архив (searchOwnAll), а без текстового запроса к нему
* подмешивается REST-довесок по свежести — тот же, что у постраничных
* getOwnRequests (новые заявки и черновики) и getOwnRequestsByStatus
* (недавно изменённые, напр. только что возвращённые на доработку). У одной и
* той же заявки довесок свежее индекса, поэтому при совпадении берётся он.
*/
export async function getAllOwnRequests(
context: WebPartContext,
types: IRequestType[],
query: string
): Promise<IMyRequest[]> {
if (types.length === 0) return [];
const searchPromise = searchOwnAll(context, types, query);
if (query.trim()) return searchPromise;
const freshPromise = Promise.all(
types.map(async (t) => {
const [created, changed] = await Promise.all([
getFreshOwnRowsForType(context, t),
getRecentlyChangedOwnRowsForType(context, t),
]);
return created.concat(changed);
})
).then((r) => r.reduce((all, x) => all.concat(x), [] as IMyRequest[]));
const [searchRows, freshRows] = await Promise.all([searchPromise, freshPromise]);
const byKey = new Map<string, IMyRequest>();
searchRows.forEach((r) => byKey.set(rowKey(r), r));
freshRows.forEach((r) => byKey.set(rowKey(r), r));
return Array.from(byKey.values()).sort((a, b) => b.created.localeCompare(a.created));
}
// Сколько строк маршрута максимум забираем за раз при дочитывании комментариев:
// на странице ≤20 заявок, у каждой обычно единицы шагов — запас многократный.
const DECISION_COMMENT_ROW_LIMIT = 2000;