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
@@ -0,0 +1,82 @@
@import '../../../../styles/design-tokens';
// Ниже этой ширины контролы встают в колонку во всю ширину — в строку они
// на телефоне не помещаются даже с переносом.
$bar-stack-breakpoint: 640px;
.bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: $sp-3;
margin-bottom: $sp-4;
@media (max-width: $bar-stack-breakpoint) {
flex-direction: column;
align-items: stretch;
}
}
// Общий вид полей — как у поиска в шапке таблицы (см. RequestsTableView.module.scss).
@mixin field {
padding: 9px 13px;
border: 1px solid $gray-300;
border-radius: $r-sm;
background-color: $white;
font-family: $font-sans;
font-size: $fs-sm;
color: $gray-900;
transition: .18s;
@include input-focus-gold;
}
.select {
@include field;
// appearance: none + своя SVG-галочка — как в shared/ui/Select. Нативная
// стрелка ОС рисуется своим шрифтом и рядом с остальными полями портала
// выглядит чужеродно (и по-разному в разных браузерах).
appearance: none;
padding-right: 34px;
background-image: url("data:image/svg+xml,%3Csvg width='12' height='8' viewBox='0 0 12 8' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1.5L6 6.5L11 1.5' stroke='%236B7280' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
cursor: pointer;
}
.period {
display: flex;
align-items: center;
gap: $sp-2;
@media (max-width: $bar-stack-breakpoint) {
flex-wrap: wrap;
}
}
.periodLabel {
font-size: $fs-sm;
color: $gray-500;
white-space: nowrap;
}
.date {
@include field;
// Нативный date-инпут на iOS без явного размера схлопывается в ноль.
min-width: 140px;
@media (max-width: $bar-stack-breakpoint) {
flex: 1;
}
}
// Цвета пунктов списка статусов — те же, что у StatusBadge. Только color:
// background-color нативный <option> показывает не во всех браузерах одинаково.
.optionDraft { color: $gray-700; }
.optionOnApproval { color: $blue-600; }
.optionApproved { color: $green-600; }
.optionRejected { color: $red-500; }
.optionReturned { color: $amber-700; }
.optionExecution { color: $navy-800; }
.optionClosed { color: $gray-500; }
@@ -0,0 +1,115 @@
import * as React from 'react';
import { Button } from '@tnsu/ui-kit';
import { REQUEST_STATUS, REQUEST_STATUS_CHOICES, IRequestType } from '../../shared/approval';
import { ITableFilters } from './requestsTableFilters';
import styles from './RequestsFilterBar.module.scss';
interface Props {
filters: ITableFilters;
onChange: (filters: ITableFilters) => void;
/** Типы для фильтра «Тип»; undefined — фильтра нет (раздел уже про один тип). */
types: IRequestType[] | undefined;
/** Показывать фильтр «Статус» — только там, где статус у заявок разный. */
showStatus: boolean;
/** Есть что сбрасывать (фильтр или сортировка не по умолчанию). */
canReset: boolean;
onReset: () => void;
}
// Цвет пункта в выпадающем списке — то же сопоставление статус→цвет, что в
// 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;
}
}
// Строка фильтров над таблицей заявок: тип, статус, период по дате создания.
// Сортировка — в заголовках колонок (SortableHeader), здесь только отбор.
export const RequestsFilterBar: React.FC<Props> = ({ filters, onChange, types, showStatus, canReset, onReset }) => {
const sortedTypes = types ? [...types].sort((a, b) => a.name.localeCompare(b.name, 'ru')) : undefined;
return (
<div className={styles.bar}>
{sortedTypes && (
<select
className={styles.select}
value={filters.typeId === undefined ? '' : String(filters.typeId)}
onChange={(e) => onChange({ ...filters, typeId: e.target.value ? Number(e.target.value) : undefined })}
aria-label="Фильтр по типу заявки"
>
<option value="">Все типы</option>
{sortedTypes.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
</select>
)}
{showStatus && (
<select
// Цвет самого <select> (закрытое состояние) — тем же классом, что и у
// выбранного <option>.
className={`${styles.select} ${filters.status ? statusOptionClassName(filters.status) : ''}`}
value={filters.status}
onChange={(e) => onChange({ ...filters, status: e.target.value })}
aria-label="Фильтр по статусу"
>
<option value="">Все статусы</option>
{REQUEST_STATUS_CHOICES.map((s) => (
<option key={s} value={s} className={statusOptionClassName(s)}>
{s}
</option>
))}
</select>
)}
<div className={styles.period}>
<span className={styles.periodLabel}>Дата с</span>
<input
type="date"
className={styles.date}
value={filters.dateFrom}
// Границы связаны: «с» не позже «по» — календарь сам не даст выбрать иначе.
max={filters.dateTo || undefined}
onChange={(e) => onChange({ ...filters, dateFrom: e.target.value })}
aria-label="Дата создания с"
/>
<span className={styles.periodLabel}>по</span>
<input
type="date"
className={styles.date}
value={filters.dateTo}
min={filters.dateFrom || undefined}
onChange={(e) => onChange({ ...filters, dateTo: e.target.value })}
aria-label="Дата создания по"
/>
</div>
{canReset && (
<Button variant="ghost" size="sm" onClick={onReset}>
Сбросить
</Button>
)}
</div>
);
};
@@ -34,48 +34,6 @@
font-weight: 600;
}
// Фильтр по статусу — только на вкладке «Инициированные». Стоит слева от поиска
// и прижат к нему: вместе это один блок «чем сузить список».
.statusFilter {
// appearance: none + своя SVG-галочка — как в shared/ui/Select. Нативная
// стрелка ОС рисуется своим шрифтом и рядом с остальными полями портала
// выглядит чужеродно (и по-разному в разных браузерах).
appearance: none;
margin-left: auto;
// Справа место под галочку; по вертикали — как у .search, чтобы оба контрола
// в шапке были одной высоты.
padding: 9px 34px 9px 13px;
border: 1px solid $gray-300;
border-radius: $r-sm;
background-color: $white;
background-image: url("data:image/svg+xml,%3Csvg width='12' height='8' viewBox='0 0 12 8' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1.5L6 6.5L11 1.5' stroke='%236B7280' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
font-family: $font-sans;
font-size: $fs-sm;
color: $gray-900;
cursor: pointer;
transition: .18s;
@include input-focus-gold;
}
// Когда фильтр есть, отступ до края уже задан им (margin-left: auto) — иначе
// поиск сам отжимается вправо.
.statusFilter + .search {
margin-left: 0;
}
// Цвет пунктов списка — те же цвета, что у StatusBadge. Только color:
// background-color нативный <option> показывает не во всех браузерах одинаково.
.optionDraft { color: $gray-700; }
.optionOnApproval { color: $blue-600; }
.optionApproved { color: $green-600; }
.optionRejected { color: $red-500; }
.optionReturned { color: $amber-700; }
.optionExecution { color: $navy-800; }
.optionClosed { color: $gray-500; }
.search {
width: 280px;
max-width: 100%;
@@ -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}
/>
@@ -0,0 +1,39 @@
@import '../../../../styles/design-tokens';
// Кнопка выглядит как обычный заголовок колонки DataTable — шрифт, регистр и
// разрядку берёт у ячейки шапки, добавляет только стрелку и курсор.
.button {
display: inline-flex;
align-items: center;
gap: $sp-1;
padding: 0;
border: 0;
background: none;
font: inherit;
color: inherit;
text-transform: inherit;
letter-spacing: inherit;
cursor: pointer;
&:hover {
color: $navy-900;
}
&:focus-visible {
outline: 2px solid $gold-500;
outline-offset: 2px;
border-radius: $r-sm;
}
}
.arrow {
color: $gray-400;
}
.active {
color: $navy-900;
.arrow {
color: $gold-600;
}
}
@@ -0,0 +1,38 @@
import * as React from 'react';
import { ISortState, SortKey } from './requestsTableFilters';
import styles from './SortableHeader.module.scss';
interface Props {
label: string;
sortKey: SortKey;
sort: ISortState;
onSort: (key: SortKey) => void;
}
// Заголовок колонки таблицы заявок с сортировкой по клику. Кнопка, а не
// кликабельный текст: так сортировка доступна с клавиатуры, а стрелка
// показывает, по какой колонке и в какую сторону отсортировано сейчас.
export const SortableHeader: React.FC<Props> = ({ label, sortKey, sort, onSort }) => {
const isActive = sort.key === sortKey;
const arrow = !isActive ? '↕' : sort.direction === 'asc' ? '↑' : '↓';
const ariaSort = !isActive ? 'не отсортировано' : sort.direction === 'asc' ? 'по возрастанию' : 'по убыванию';
return (
<button
type="button"
className={`${styles.button} ${isActive ? styles.active : ''}`}
onClick={(e) => {
// Шапка таблицы — не строка, но клик всё равно не должен никуда всплывать.
e.stopPropagation();
onSort(sortKey);
}}
title={`Сортировать: ${label}`}
aria-label={`${label}, ${ariaSort}`}
>
{label}
<span className={styles.arrow} aria-hidden="true">
{arrow}
</span>
</button>
);
};
@@ -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;
@@ -0,0 +1,126 @@
import { IMyRequest } from './models/IMyRequest';
import {
DEFAULT_SORT,
EMPTY_FILTERS,
filterRows,
hasActiveFilters,
isDefaultSort,
localDateKey,
sortRows,
toggleSort,
} from './requestsTableFilters';
function request(over: Partial<IMyRequest> = {}): IMyRequest {
return {
id: 1,
code: '4-2609-1',
title: 'Заявка',
status: 'Черновик',
authorEmail: 'ivanov@tnsu.kz',
authorName: 'Иванов Иван',
created: '2026-09-24T10:00:00',
requestTypeId: 4,
requestTypeName: 'Заявка на подбор',
section: 'Кадры',
listId: '/sites/requests/Lists/List4',
siteUrl: 'https://tnsukz.sharepoint.com/sites/requests',
formKey: 'recruiting-selection',
kind: 'own',
...over,
};
}
const codes = (rows: IMyRequest[]): string[] => rows.map((r) => r.code);
describe('toggleSort', () => {
it('по той же колонке меняет направление', () => {
expect(toggleSort({ key: 'date', direction: 'desc' }, 'date')).toEqual({ key: 'date', direction: 'asc' });
expect(toggleSort({ key: 'date', direction: 'asc' }, 'date')).toEqual({ key: 'date', direction: 'desc' });
});
it('по новой колонке начинает с возрастания', () => {
expect(toggleSort(DEFAULT_SORT, 'code')).toEqual({ key: 'code', direction: 'asc' });
});
});
describe('hasActiveFilters / isDefaultSort', () => {
it('пустые фильтры и сортировка по умолчанию не считаются уточнением', () => {
expect(hasActiveFilters(EMPTY_FILTERS)).toBe(false);
expect(isDefaultSort(DEFAULT_SORT)).toBe(true);
});
it('любое заполненное поле — активный фильтр', () => {
expect(hasActiveFilters({ ...EMPTY_FILTERS, typeId: 4 })).toBe(true);
expect(hasActiveFilters({ ...EMPTY_FILTERS, dateTo: '2026-09-30' })).toBe(true);
});
});
describe('localDateKey', () => {
it('отдаёт местную дату в виде YYYY-MM-DD', () => {
expect(localDateKey('2026-09-05T10:00:00')).toBe('2026-09-05');
});
it('пустая или битая дата — пустая строка', () => {
expect(localDateKey('')).toBe('');
expect(localDateKey('не дата')).toBe('');
});
});
describe('filterRows', () => {
const rows = [
request({ code: '4-2609-1', requestTypeId: 4, status: 'Черновик', created: '2026-09-20T10:00:00' }),
request({ code: '5-2609-2', requestTypeId: 5, status: 'На согласовании', created: '2026-09-24T10:00:00' }),
request({ code: '4-2609-3', requestTypeId: 4, status: 'На согласовании', created: '2026-09-26T10:00:00' }),
];
it('без фильтров возвращает всё', () => {
expect(filterRows(rows, EMPTY_FILTERS)).toHaveLength(3);
});
it('по типу и статусу одновременно', () => {
expect(codes(filterRows(rows, { ...EMPTY_FILTERS, typeId: 4, status: 'На согласовании' }))).toEqual([
'4-2609-3',
]);
});
it('период включает обе границы', () => {
expect(codes(filterRows(rows, { ...EMPTY_FILTERS, dateFrom: '2026-09-24', dateTo: '2026-09-26' }))).toEqual([
'5-2609-2',
'4-2609-3',
]);
});
it('заявка без даты не проходит фильтр по периоду', () => {
expect(filterRows([request({ created: '' })], { ...EMPTY_FILTERS, dateFrom: '2026-01-01' })).toHaveLength(0);
});
});
describe('sortRows', () => {
it('номер сравнивается по числам, а не строкой', () => {
const rows = [request({ code: '4-2609-100' }), request({ code: '4-2609-23' }), request({ code: '4-2608-500' })];
expect(codes(sortRows(rows, { key: 'code', direction: 'asc' }))).toEqual([
'4-2608-500',
'4-2609-23',
'4-2609-100',
]);
});
it('заявка без кода сортируется по Id', () => {
const rows = [request({ code: '', id: 7 }), request({ code: '', id: 3 })];
expect(sortRows(rows, { key: 'code', direction: 'desc' }).map((r) => r.id)).toEqual([7, 3]);
});
it('при равенстве по колонке — свежие сверху', () => {
const rows = [
request({ code: 'a', requestTypeName: 'Отпуск', created: '2026-09-01T10:00:00' }),
request({ code: 'b', requestTypeName: 'Отпуск', created: '2026-09-02T10:00:00' }),
];
expect(codes(sortRows(rows, { key: 'type', direction: 'asc' }))).toEqual(['b', 'a']);
});
it('не мутирует исходный массив', () => {
const rows = [request({ code: '4-2609-2' }), request({ code: '4-2609-1' })];
sortRows(rows, { key: 'code', direction: 'asc' });
expect(codes(rows)).toEqual(['4-2609-2', '4-2609-1']);
});
});
@@ -0,0 +1,126 @@
import { IMyRequest } from './models/IMyRequest';
// Фильтры и сортировка таблиц заявок (RequestsTableView). Чистые функции без
// React и без сети — таблица применяет их к ПОЛНОМУ списку раздела, а не к
// одной странице (иначе отсортировались бы 20 строк, а не все заявки).
/** Колонки, по которым можно сортировать. */
export type SortKey = 'code' | 'type' | 'date' | 'status';
export type SortDirection = 'asc' | 'desc';
export interface ISortState {
key: SortKey;
direction: SortDirection;
}
export interface ITableFilters {
/** Id типа заявки; undefined — все типы. */
typeId: number | undefined;
/** Статус заявки; '' — все статусы. */
status: string;
/** Период по дате создания, 'YYYY-MM-DD' (значение <input type="date">); '' — без границы. */
dateFrom: string;
dateTo: string;
}
/** Порядок по умолчанию — свежие сверху, как таблицы сортировались всегда. */
export const DEFAULT_SORT: ISortState = { key: 'date', direction: 'desc' };
export const EMPTY_FILTERS: ITableFilters = { typeId: undefined, status: '', dateFrom: '', dateTo: '' };
export function hasActiveFilters(filters: ITableFilters): boolean {
return filters.typeId !== undefined || !!filters.status || !!filters.dateFrom || !!filters.dateTo;
}
export function isDefaultSort(sort: ISortState): boolean {
return sort.key === DEFAULT_SORT.key && sort.direction === DEFAULT_SORT.direction;
}
/**
* Следующее состояние сортировки по клику на заголовок: по той же колонке —
* смена направления, по другой — сначала по возрастанию.
*/
export function toggleSort(current: ISortState, key: SortKey): ISortState {
if (current.key === key) {
return { key, direction: current.direction === 'asc' ? 'desc' : 'asc' };
}
return { key, direction: 'asc' };
}
/**
* Дата создания в МЕСТНОМ времени как 'YYYY-MM-DD' — в том же виде, что
* значение <input type="date">. Created хранится в UTC: заявка, созданная в
* 01:00 по Алматы, в UTC ещё «вчерашняя», и сравнение по сырой ISO-строке
* выкинуло бы её из фильтра за сегодня.
*/
export function localDateKey(iso: string): string {
if (!iso) return '';
const d = new Date(iso);
if (isNaN(d.getTime())) return '';
const mm = d.getMonth() + 1 < 10 ? `0${d.getMonth() + 1}` : `${d.getMonth() + 1}`;
const dd = d.getDate() < 10 ? `0${d.getDate()}` : `${d.getDate()}`;
return `${d.getFullYear()}-${mm}-${dd}`;
}
export function filterRows(rows: IMyRequest[], filters: ITableFilters): IMyRequest[] {
return rows.filter((r) => {
if (filters.typeId !== undefined && r.requestTypeId !== filters.typeId) return false;
if (filters.status && r.status !== filters.status) return false;
if (filters.dateFrom || filters.dateTo) {
// Строки 'YYYY-MM-DD' одинаковой длины сравниваются как даты.
const day = localDateKey(r.created);
if (!day) return false;
if (filters.dateFrom && day < filters.dateFrom) return false;
if (filters.dateTo && day > filters.dateTo) return false;
}
return true;
});
}
/**
* Части номера заявки как числа: «4-2609-42» → [4, 2609, 42]. Сравнивать номер
* строкой нельзя — «4-2609-100» оказался бы раньше «4-2609-23». У заявок без
* кода (созданы до появления колонки) номер — Id элемента.
*/
function codeParts(r: IMyRequest): number[] {
if (!r.code) return [r.id];
return r.code.split('-').map((part) => Number(part) || 0);
}
function compareCodes(a: IMyRequest, b: IMyRequest): number {
const pa = codeParts(a);
const pb = codeParts(b);
const length = Math.max(pa.length, pb.length);
for (let i = 0; i < length; i++) {
const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
if (diff !== 0) return diff;
}
return 0;
}
function compareBy(key: SortKey, a: IMyRequest, b: IMyRequest): number {
switch (key) {
case 'code':
return compareCodes(a, b);
case 'type':
return a.requestTypeName.localeCompare(b.requestTypeName, 'ru');
case 'status':
return a.status.localeCompare(b.status, 'ru');
case 'date':
return a.created.localeCompare(b.created);
}
}
/** Новый массив; при равенстве по выбранной колонке — свежие сверху. */
export function sortRows(rows: IMyRequest[], sort: ISortState): IMyRequest[] {
const sign = sort.direction === 'asc' ? 1 : -1;
return [...rows].sort((a, b) => {
const primary = compareBy(sort.key, a, b) * sign;
return primary !== 0 ? primary : b.created.localeCompare(a.created);
});
}
/** Строка-ключ состояния — для ключей кеша страниц и загрузки таблицы. */
export function refinementSignature(filters: ITableFilters, sort: ISortState): string {
return [filters.typeId ?? '', filters.status, filters.dateFrom, filters.dateTo, sort.key, sort.direction].join('|');
}
@@ -0,0 +1,99 @@
import * as React from 'react';
import {
DEFAULT_SORT,
EMPTY_FILTERS,
hasActiveFilters,
isDefaultSort,
ISortState,
ITableFilters,
refinementSignature,
SortKey,
toggleSort,
} from './requestsTableFilters';
/** Фильтры и сортировка одной таблицы (см. requestsTableFilters.ts). */
interface IRefinement {
filters: ITableFilters;
sort: ISortState;
}
const DEFAULT_REFINEMENT: IRefinement = { filters: EMPTY_FILTERS, sort: DEFAULT_SORT };
interface IOptions {
/** Ключ таблицы: раздел (+ тип для «Все заявки»). */
tableKey: string;
/** Статус живёт в маршруте, а не здесь (вкладка «Инициированные»). */
statusFromRoute: boolean;
routeStatus: string;
onRouteStatusChange?: (status: string) => void;
page: number;
onPageChange: (page: number) => void;
}
export interface ITableRefinement {
/** Фильтры, которые таблица применяет сама (без статуса из маршрута). */
localFilters: ITableFilters;
/** То, что показывает панель фильтров (со статусом из маршрута). */
barFilters: ITableFilters;
sort: ISortState;
/** Список уточнён сверх обычной постраничной загрузки — нужен полный набор. */
isRefined: boolean;
/** Строка-ключ состояния для кешей и зависимостей загрузки. */
signature: string;
canReset: boolean;
onFiltersChange: (filters: ITableFilters) => void;
onSort: (key: SortKey) => void;
onReset: () => void;
}
/**
* Состояние фильтров и сортировки таблицы заявок.
*
* СВОЁ у каждой таблицы: RequestsTableView один на все разделы, и без ключа по
* разделу фильтр «Тип: Отпуск» из «Моих заявок» переезжал бы в «На
* согласовании». Живёт, пока смонтирована таблица: переживает открытие заявки
* и переходы между её разделами, но не уход на «Поиск»/«Новую заявку».
*
* Статус на «Инициированных» живёт в маршруте (так было и до фильтров) и
* отбирается сервером — здесь его не дублируем, чтобы не было двух источников истины.
*/
export function useTableRefinement(options: IOptions): ITableRefinement {
const { tableKey, statusFromRoute, routeStatus, onRouteStatusChange, page, onPageChange } = options;
const [byTable, setByTable] = React.useState<Record<string, IRefinement>>({});
const refinement = byTable[tableKey] ?? DEFAULT_REFINEMENT;
const { sort } = refinement;
const localFilters: ITableFilters = statusFromRoute ? { ...refinement.filters, status: '' } : refinement.filters;
const barFilters: ITableFilters = statusFromRoute ? { ...localFilters, status: routeStatus } : localFilters;
const isRefined = hasActiveFilters(localFilters) || !isDefaultSort(sort);
// Другой набор строк — прежний номер страницы в нём ничего не значит, поэтому
// страница сбрасывается на первую. Кроме случая, когда заодно меняется статус
// в маршруте: onRouteStatusChange сам ведёт на первую страницу, а второй
// переход (onPageChange) ушёл бы от ещё старого маршрута и откатил бы статус.
const save = (next: IRefinement, routeStatusChanged: boolean): void => {
setByTable((prev) => ({ ...prev, [tableKey]: next }));
if (!routeStatusChanged && page !== 1) onPageChange(1);
};
const changeRouteStatus = (status: string): boolean => {
if (!statusFromRoute || status === routeStatus || !onRouteStatusChange) return false;
onRouteStatusChange(status);
return true;
};
return {
localFilters,
barFilters,
sort,
isRefined,
signature: refinementSignature(localFilters, sort),
canReset: isRefined || (statusFromRoute && !!routeStatus),
onFiltersChange: (next) => {
const routeStatusChanged = changeRouteStatus(next.status);
save({ ...refinement, filters: { ...next, status: statusFromRoute ? '' : next.status } }, routeStatusChanged);
},
onSort: (key) => save({ ...refinement, sort: toggleSort(sort, key) }, false),
onReset: () => save(DEFAULT_REFINEMENT, changeRouteStatus('')),
};
}