Во всех разделах со списками заявок (На согласовании, На исполнении, Доработка, Мои заявки, Все заявки): - фильтры по типу, статусу и периоду даты создания, кнопка «Сбросить»; - сортировка по клику на заголовок: номер (по числам), тип, дата, статус. Без фильтров загрузка прежняя, постраничная. При активном фильтре или сортировке загружается полный список раздела и обрабатывается в памяти, иначе отсортировалась бы только текущая страница. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user