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
@@ -4,8 +4,11 @@ import {
ApprovalQueueKind,
filterPendingByQuery,
getAllRequestsForType,
getAllOwnRequests,
getAllRequestsRowsForType,
getApprovalQueue,
getApprovalQueueIndex,
getApprovalQueueRows,
getLinkedTypes,
getOwnRequests,
getOwnRequestsByStatus,
@@ -15,7 +18,7 @@ import {
} from './myRequests.service';
import { IMyRequest } from './models/IMyRequest';
import { DataTable, IDataTableColumn, IconButton } from '@tnsu/ui-kit';
import { REQUEST_STATUS, REQUEST_STATUS_CHOICES, IRequestType, StatusBadge } from '../../shared/approval';
import { REQUEST_STATUS, IRequestType, StatusBadge } from '../../shared/approval';
import { confirmAndDeleteDraft, isDeletableStatus } from '../../shared/draftDeletion';
import { friendlyErrorMessage } from '../../shared/diagnostics';
import { getFormComponent } from '../../forms/registry';
@@ -24,6 +27,10 @@ import { perfScope } from '../../shared/perfTrace';
import { useRoutedQuery } from '../../shared/usePersistedQuery';
import { IPage, pageKey, pageSignature, readPage, writePage } from './pageCache';
import { Pager } from './Pager';
import { RequestsFilterBar } from './RequestsFilterBar';
import { SortableHeader } from './SortableHeader';
import { filterRows, hasActiveFilters, ISortState, SortKey, sortRows } from './requestsTableFilters';
import { useTableRefinement } from './useTableRefinement';
import styles from './RequestsTableView.module.scss';
const PAGE_SIZE = 20;
@@ -90,31 +97,7 @@ const EMPTY_TEXTS: Record<ListView, string> = {
all: 'Заявок этого типа пока нет.',
};
// Цвет пункта в выпадающем списке фильтра — то же сопоставление статус→цвет,
// что в StatusBadge (VARIANT), только цвет текста, а не бейдж целиком: внутри
// нативного <option> нельзя отрендерить компонент, а background-color на нём
// браузеры показывают ненадёжно (в отличие от color). Если сопоставление там
// поменяется — поправить и здесь, второй раз найти дублирование негде.
function statusOptionClassName(status: string): string {
switch (status) {
case REQUEST_STATUS.onApproval:
case REQUEST_STATUS.onExecution:
return styles.optionOnApproval;
case REQUEST_STATUS.approved:
case REQUEST_STATUS.executed:
return styles.optionApproved;
case REQUEST_STATUS.rejected:
return styles.optionRejected;
case REQUEST_STATUS.returned:
return styles.optionReturned;
case REQUEST_STATUS.execution:
return styles.optionExecution;
case REQUEST_STATUS.closed:
return styles.optionClosed;
default:
return styles.optionDraft;
}
}
const NO_MATCHES_TEXT = 'Нет заявок, подходящих под выбранные фильтры.';
// Колонки: «Автор» нужен там, где заявка чужая; «Статус» — где он не следует из
// самого раздела. В истории решений важно и то, и другое: заявка чужая, а её
@@ -224,11 +207,16 @@ const Table: React.FC<{
rows: IMyRequest[];
view: ListView;
statusFilter: string;
sort: ISortState;
onSort: (key: SortKey) => void;
onOpen: (request: IMyRequest) => void;
/** Удалить черновик; задан только там, где в таблице бывают свои черновики. */
onDelete?: (request: IMyRequest) => void;
}> = ({ rows, view, statusFilter, onOpen, onDelete }) => {
}> = ({ rows, view, statusFilter, sort, onSort, onOpen, onDelete }) => {
const cols = columnsFor(view, statusFilter);
const sortable = (label: string, key: SortKey): React.ReactNode => (
<SortableHeader label={label} sortKey={key} sort={sort} onSort={onSort} />
);
// Колонки для DataTable строим динамически: базовые всегда, автор/статус/
// комментарий — по columnsFor (зависит от раздела и фильтра). Кастомные ячейки
@@ -238,10 +226,10 @@ const Table: React.FC<{
// (в каждом списке SharePoint нумерация с единицы), поэтому после архивации
// в таблице встречались разные заявки под одним номером. Кода нет у заявок,
// созданных до появления колонки — для них показываем Id, как раньше.
{ key: 'code', title: 'Номер', width: '130px', render: (r) => r.code || `#${r.id}` },
{ key: 'type', title: 'Тип', width: '200px', render: (r) => r.requestTypeName },
{ key: 'code', title: sortable('Номер', 'code'), width: '130px', render: (r) => r.code || `#${r.id}` },
{ key: 'type', title: sortable('Тип', 'type'), width: '200px', render: (r) => r.requestTypeName },
{ key: 'title', title: 'Название', render: (r) => r.title || r.code || `#${r.id}` },
{ key: 'date', title: 'Дата', width: '110px', render: (r) => formatDate(r.created) }
{ key: 'date', title: sortable('Дата', 'date'), width: '110px', render: (r) => formatDate(r.created) }
];
if (cols.author) {
columns.push({
@@ -254,7 +242,7 @@ const Table: React.FC<{
if (cols.status) {
columns.push({
key: 'status',
title: 'Статус',
title: sortable('Статус', 'status'),
width: '190px',
render: (r) => (r.status ? <StatusBadge status={r.status} size="sm" /> : '—')
});
@@ -366,6 +354,26 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
const [input, setInput] = useRoutedQuery(query, onQueryChange);
const debouncedQuery = query;
// Уточнён ли список (refinement.isRefined) сверх того, что умеет обычная
// постраничная загрузка. Если да — таблица грузит ПОЛНЫЙ список раздела и сама
// фильтрует, сортирует и режет его на страницы (см. fetchRefinedPage). Если
// нет — всё как раньше.
const refinement = useTableRefinement({
tableKey: `${view}|${allRequestsType?.id ?? ''}`,
statusFromRoute: view === 'own:created',
routeStatus: statusFilter,
onRouteStatusChange: onStatusFilterChange,
page,
onPageChange,
});
const { localFilters, sort, isRefined } = refinement;
const refinementSig = refinement.signature;
// Полный список раздела для режима с фильтрами — последний загруженный.
// Листание страниц и смена фильтра его переиспользуют (заново по сети не
// идём), а автообновление и rp-requests-changed перечитывают.
const fullListRef = React.useRef<{ key: string; rows: IMyRequest[] } | undefined>(undefined);
// Какой ИМЕННО таблице принадлежат лежащие в состоянии rows/totalRows.
// Компонент один на все разделы (см. LIST_VIEWS в RequestPortal), поэтому при
// переключении раздела его состояние ещё хранит данные ПРЕДЫДУЩЕГО, пока не
@@ -373,7 +381,7 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
// по чужому totalRows и сбивал страницу на первую — из-за чего запомненная
// страница затиралась при каждом переходе по левому меню.
// Страница в ключ НЕ входит: totalRows один на всю таблицу, а не на страницу.
const dataKey = `${view}|${statusFilter}|${debouncedQuery.trim()}|${allRequestsType?.id ?? ''}`;
const dataKey = `${view}|${statusFilter}|${debouncedQuery.trim()}|${allRequestsType?.id ?? ''}|${refinementSig}`;
const [loadedKey, setLoadedKey] = React.useState<string>('');
// Страницу при смене запроса здесь НЕ сбрасываем: и запрос, и страница живут
@@ -396,7 +404,14 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
React.useEffect(() => {
if (types === undefined) return;
let cancelled = false;
const cacheKey = pageKey('table', [view, statusFilter, debouncedQuery.trim(), page, allRequestsType?.id ?? 0]);
const cacheKey = pageKey('table', [
view,
statusFilter,
debouncedQuery.trim(),
page,
allRequestsType?.id ?? 0,
refinementSig,
]);
// Сразу показываем то, что уже загружали для ЭТОЙ вкладки (и этого запроса
// и страницы), а обновление идёт фоном. Компонент один на обе вкладки —
@@ -420,7 +435,49 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
// медленного источника. Остальные ветки (approvals/execution/поиск) его
// просто не принимают — там нет пары «Search + довесок» с независимо
// валидным промежуточным результатом.
const fetchRows = async (onPartial?: (p: IPage) => void): Promise<IPage> => {
// ПОЛНЫЙ список раздела (с учётом поиска и статуса из маршрута), без нарезки
// на страницы — сырьё для фильтров и сортировки. Источники те же, что у
// постраничной загрузки ниже, только без StartRow/RowLimit.
const fetchFullRows = async (): Promise<IMyRequest[]> => {
if (view === 'all') {
return allRequestsType ? getAllRequestsRowsForType(context, allRequestsType, q) : [];
}
if (view === 'rework' || view === 'own:created') {
const rows = await getAllOwnRequests(context, types, q);
const fixedStatus = view === 'rework' ? REQUEST_STATUS.returned : statusFilter;
return fixedStatus ? rows.filter((r) => r.status === fixedStatus) : rows;
}
const queue = QUEUE_BY_VIEW[view];
if (q.length === 0) return (await getApprovalQueueRows(context, queue)).rows;
const idx = await getApprovalQueueIndex(context, queue);
return filterPendingByQuery(context, idx.types, idx.rows, debouncedQuery);
};
const fullKey = `${view}|${statusFilter}|${q}|${allRequestsType?.id ?? ''}`;
// Режим с фильтрами: полный список → фильтр → сортировка → страница.
// reuseFull — взять уже загруженный полный список, если он от этой же
// таблицы и запроса (листание, смена фильтра); иначе перечитать.
const fetchAndRememberFull = async (): Promise<IMyRequest[]> => {
const rows = await fetchFullRows();
fullListRef.current = { key: fullKey, rows };
return rows;
};
const fetchRefinedPage = async (reuseFull: boolean): Promise<IPage> => {
const cachedFull = fullListRef.current;
const full =
reuseFull && cachedFull && cachedFull.key === fullKey ? cachedFull.rows : await fetchAndRememberFull();
const refined = sortRows(filterRows(full, localFilters), sort);
const pageRows = refined.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
await hydratePendingAuthors(context, types, pageRows);
return { rows: pageRows, totalRows: refined.length };
};
const fetchRows = async (onPartial?: (p: IPage) => void, reuseFull = false): Promise<IPage> => {
if (isRefined) return fetchRefinedPage(reuseFull);
// «Доработка» — тот же список, что «Инициированные», просто со статусом,
// зашитым в раздел, а не выбранным в фильтре.
if (view === 'rework' || (view === 'own:created' && statusFilter)) {
@@ -462,8 +519,8 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
// для видимой страницы — как и автор в hydratePendingAuthors. Промежуточный
// показ (onPartial) комментарий ещё не несёт — он дочитывается только для
// ИТОГОВОГО результата, полный показ его донесёт следом.
const fetchPage = async (onPartial?: (p: IPage) => void): Promise<IPage> => {
const result = await fetchRows(onPartial);
const fetchPage = async (onPartial?: (p: IPage) => void, reuseFull = false): Promise<IPage> => {
const result = await fetchRows(onPartial, reuseFull);
const decisionStatus = decisionStatusFor(view, statusFilter);
if (decisionStatus) {
await hydrateDecisionComments(context, types, result.rows, decisionStatus);
@@ -471,7 +528,9 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
return result;
};
const load = async (forceRender = false): Promise<void> => {
// reuseFull — только для первой загрузки эффекта (см. fetchRefinedPage):
// автообновление и rp-requests-changed обязаны читать данные заново.
const load = async (forceRender = false, reuseFull = false): Promise<void> => {
try {
// Промежуточный показ — ТОЛЬКО когда для этой страницы прямо СЕЙЧАС
// ничего не закешировано (первый заход, экран пуст). Проверяем на
@@ -495,7 +554,7 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
setTotalRows(partial.totalRows);
};
const fresh = await perfScope(`таблица ${view} стр.${page}`, () => fetchPage(onPartial));
const fresh = await perfScope(`таблица ${view} стр.${page}`, () => fetchPage(onPartial, reuseFull));
if (cancelled) return;
// Перерисовываем, только если данные реально изменились — иначе при
@@ -520,7 +579,7 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
}
};
load().catch(() => undefined); // ошибки уже обработаны внутри load через setError
load(false, true).catch(() => undefined); // ошибки уже обработаны внутри load через setError
const unsubscribe = onRequestsChanged((force) => {
// Явный повторный клик по уже открытому пункту меню — пользователь ждёт
// видимого обновления, а не молчаливой подмены данных за спиной. Прежние
@@ -541,7 +600,10 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
unsubscribe();
window.clearInterval(timer);
};
}, [context, types, view, statusFilter, debouncedQuery, page, dataKey, allRequestsType]);
// localFilters пересобирается на каждый рендер (см. выше) — в зависимостях его
// заменяет refinementSig, по которому он и меняется.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [context, types, view, statusFilter, debouncedQuery, page, dataKey, allRequestsType, isRefined, refinementSig]);
// Все ветки отдают уже нарезанную страницу — просто показываем как есть.
const q = debouncedQuery.trim();
@@ -581,24 +643,6 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
{view === 'all' && allRequestsType ? `Все заявки: ${allRequestsType.name}` : HEADINGS[view]}
{rows && <span className={styles.count}>{total}</span>}
</h2>
{view === 'own:created' && onStatusFilterChange && (
<select
// Цвет самого <select> (закрытое состояние) — тем же классом, что и
// у выбранного <option>: statusFilter задаёт нейтральные border/фон,
// а конкретный statusOptionClassName(statusFilter) перекрывает color.
className={`${styles.statusFilter} ${statusFilter ? statusOptionClassName(statusFilter) : ''}`}
value={statusFilter}
onChange={(e) => onStatusFilterChange(e.target.value)}
aria-label="Фильтр по статусу"
>
<option value="">Все статусы</option>
{REQUEST_STATUS_CHOICES.map((s) => (
<option key={s} value={s} className={statusOptionClassName(s)}>
{s}
</option>
))}
</select>
)}
<input
type="search"
className={styles.search}
@@ -608,12 +652,27 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
/>
</div>
<RequestsFilterBar
filters={refinement.barFilters}
onChange={refinement.onFiltersChange}
// «Все заявки» уже показывают один выбранный тип — фильтр по типу там пуст.
types={view === 'all' ? undefined : types}
// Статус — там же, где его колонка: в остальных разделах он у всех строк один.
showStatus={columnsFor(view, '').status}
canReset={refinement.canReset}
onReset={refinement.onReset}
/>
{error && <div className={styles.error}>Не удалось загрузить заявки: {error}</div>}
{deleteError && <div className={styles.error}>{deleteError}</div>}
{!error && !loaded && <div className={styles.empty}>Загрузка…</div>}
{!error && loaded && total === 0 && (
<div className={styles.empty}>
{q ? `По запросу «${debouncedQuery}» ничего не найдено.` : EMPTY_TEXTS[view]}
{q
? `По запросу «${debouncedQuery}» ничего не найдено.`
: hasActiveFilters(refinement.barFilters)
? NO_MATCHES_TEXT
: EMPTY_TEXTS[view]}
</div>
)}
{!error && rows && total > 0 && (
@@ -622,6 +681,8 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
rows={visible}
view={view}
statusFilter={statusFilter}
sort={sort}
onSort={refinement.onSort}
onOpen={onOpen}
onDelete={view === 'own:created' ? handleDelete : undefined}
/>