Files
request-portal/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx
T
Тимур Городецкий ea07e8d5ab feat: столбец «Исполнено» в таблицах заявок — кто и когда выполнил
Столбец «Исполнитель» показывает только текущего держателя, и было не видно,
кто из исполнителей уже отметил «Выполнено». Теперь видно ФИО и время
каждой отметки; столбец появляется, только если на странице есть такие.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 16:42:07 +05:00

853 lines
49 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import * as React from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import {
ApprovalQueueKind,
filterPendingByQuery,
getAllRequestsForType,
getAllOwnRequests,
getAllRequestsRowsForType,
getApprovalQueue,
getApprovalQueueIndex,
getApprovalQueueRows,
getLinkedTypes,
getOwnRequests,
getOwnRequestsByStatus,
hydrateCurrentAssignees,
hydrateDecisionComments,
hydrateExecutedSteps,
hydratePendingAuthors,
searchOwnRequests,
} from './myRequests.service';
import { hydrateStartDates } from './startDates.service';
import { IMyRequest } from './models/IMyRequest';
import { DataTable, IDataTableColumn, IconButton } from '@tnsu/ui-kit';
import { REQUEST_STATUS, IRequestType, StatusBadge } from '../../shared/approval';
import { confirmAndDeleteDraft, isDeletableStatus } from '../../shared/draftDeletion';
import { friendlyErrorMessage } from '../../shared/diagnostics';
import { getFormComponent } from '../../forms/registry';
import { FORM_ID as WORKPLACE_SETUP_FORM_ID } from '../../forms/WorkplaceSetup/model';
import { onRequestsChanged } from '../../shared/requestEvents';
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 { ActiveFiltersBar } from './ActiveFiltersBar';
import { effectiveUserEmail } from '../../shared/impersonation';
import { RequestHistoryDialog } from './RequestHistoryDialog';
import { SortableHeader } from './SortableHeader';
import { filterRows, hasActiveFilters, ISortState, SortKey, sortRows } from './requestsTableFilters';
import { useTableRefinement } from './useTableRefinement';
import { formatDateTime } from '../../shared/ui/FieldChanges/formatDateTime';
import styles from './RequestsTableView.module.scss';
const PAGE_SIZE = 20;
// Автообновление открытых таблиц: раз в 10 минут перечитываем данные, чтобы
// вкладка, оставленная открытой надолго, не показывала устаревший список.
// Обновление фоновое — строки с экрана не пропадают и перерисовываются, только
// если данные реально изменились (см. pageSignature).
const AUTO_REFRESH_MS = 10 * 60 * 1000;
/**
* Что именно показывает таблица. Один компонент обслуживает все списки заявок
* портала, а различаются они только источником строк и набором колонок:
*
* 'approvals' | 'execution' — очереди действий (из строк маршрута);
* 'rework' — свои заявки, возвращённые на доработку;
* 'own:created' — свои заявки (+ фильтр по статусу);
* 'own:approved'|'own:rejected'|'own:executed' — история своих решений.
*/
export type ListView =
| 'approvals'
| 'execution'
| 'rework'
| 'own:created'
| 'own:approved'
| 'own:rejected'
| 'own:executed'
| 'all';
// Представления, которые строятся на строках маршрута пользователя, и их очередь
// в сервисе. Всё, чего здесь нет ('rework'/'own:created'), читается из списка
// данных по автору.
const QUEUE_BY_VIEW: { [key: string]: ApprovalQueueKind } = {
approvals: 'approvals',
execution: 'execution',
'own:approved': 'approved',
'own:rejected': 'rejected',
'own:executed': 'executed',
};
// 'all' — заголовок и текст пустоты зависят от выбранного типа заявки
// (см. allRequestsType и heading/emptyText ниже), здесь только запасной
// вариант на случай, пока тип ещё не выбран/не загрузился.
const HEADINGS: Record<ListView, string> = {
approvals: 'Ждут вашего согласования',
execution: 'Ждут вашего исполнения',
rework: 'Возвращены вам на доработку',
'own:created': 'Инициированные мной',
'own:approved': 'Согласованные мной',
'own:rejected': 'Отклонённые мной',
'own:executed': 'Выполненные мной',
all: 'Все заявки',
};
const EMPTY_TEXTS: Record<ListView, string> = {
approvals: 'Нет заявок, ждущих вашего согласования.',
execution: 'Нет заявок, ждущих вашего исполнения.',
rework: 'Нет заявок, возвращённых вам на доработку.',
'own:created': 'Вы ещё не создавали заявок.',
'own:approved': 'Вы ещё ничего не согласовали.',
'own:rejected': 'Вы ещё ничего не отклоняли.',
'own:executed': 'Вы ещё ничего не выполнили.',
all: 'Заявок этого типа пока нет.',
};
const NO_MATCHES_TEXT = 'Нет заявок, подходящих под выбранные фильтры.';
// Колонки: «Автор» нужен там, где заявка чужая; «Статус» — где он не следует из
// самого раздела. В истории решений важно и то, и другое: заявка чужая, а её
// текущий статус мог уйти дальше вашего решения.
//
// 'rework' — свои заявки (автор не нужен) И все с одним статусом «Возвращена на
// доработку», раз именно по нему раздел и отобран (см. getOwnRequestsByStatus):
// колонка из одинаковых плашек ничего не сообщает, только шумит. Та же логика,
// что у очередей согласования/исполнения.
//
// 'own:created' — статус нужен, только пока показаны ВСЕ заявки. Стоит выбрать
// его в фильтре, и колонка вырождается в тот же столбец одинаковых плашек,
// поэтому она зависит не от одного вида, а ещё и от фильтра.
//
// Статус решения, ОБЩИЙ для всех строк раздела, — если он есть, показывать его
// колонкой незачем (столбец одинаковых плашек), зато полезен комментарий
// согласующего: почему вернули или почему отклонили. Именно за этим в такие
// списки и заходят.
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 {
author: boolean;
status: boolean;
/** Колонка «Исполнитель» — у кого заявка сейчас (см. showsAssignee). */
assignee: boolean;
/** Колонка «Исполнено» — кто из исполнителей уже выполнил (см. showsExecuted). */
executed: boolean;
/** Колонка «Дата выхода» — только «Все заявки» по организации рабочего места. */
startDate?: boolean;
/** Заголовок колонки с комментарием; undefined — колонки нет. */
commentLabel?: string;
}
// «Исполнитель» нужен там, где заявка может быть у кого угодно: свои поданные
// заявки, история своих решений (заявка ушла дальше по маршруту) и «Все
// заявки». В очередях согласования/исполнения и в «Доработке» держатель —
// всегда сам пользователь, в «Отклонённых мной» — никто: колонка вышла бы из
// одинаковых значений.
//
// На «Инициированных» с фильтром по статусу — только если при этом статусе у
// заявки вообще бывает держатель по маршруту. При «Возвращена на доработку»
// это всегда сам автор, при итоговых статусах — никто.
const ASSIGNEE_VIEWS: ListView[] = ['own:created', 'own:approved', 'own:executed', 'all'];
const STATUSES_WITH_ROUTE_HOLDER: string[] = [REQUEST_STATUS.onApproval, REQUEST_STATUS.execution];
function showsAssignee(view: ListView, statusFilter: string): boolean {
if (ASSIGNEE_VIEWS.indexOf(view) < 0) return false;
if (view === 'own:created' && statusFilter) return STATUSES_WITH_ROUTE_HOLDER.indexOf(statusFilter) >= 0;
return true;
}
// «Исполнено» — в тех же разделах, что и «Исполнитель»: там заявку смотрят со
// стороны, и важно, как далеко продвинулось исполнение. На «Инициированных» с
// фильтром по статусу — только при статусах, где выполнившие уже бывают.
const STATUSES_WITH_EXECUTED: string[] = [REQUEST_STATUS.execution, REQUEST_STATUS.closed];
function showsExecuted(view: ListView, statusFilter: string): boolean {
if (ASSIGNEE_VIEWS.indexOf(view) < 0) return false;
if (view === 'own:created' && statusFilter) return STATUSES_WITH_EXECUTED.indexOf(statusFilter) >= 0;
return true;
}
/** Дата выхода сотрудника есть только у заявки на организацию рабочего места, и показываем её во «Все заявки». */
function showsStartDate(view: ListView, type: IRequestType | undefined): boolean {
return view === 'all' && type?.pageUrl === WORKPLACE_SETUP_FORM_ID;
}
function columnsFor(view: ListView, statusFilter: string, startDate = false): ITableColumns {
const assignee = showsAssignee(view, statusFilter);
const executed = showsExecuted(view, statusFilter);
const decisionStatus = decisionStatusFor(view, statusFilter);
const commentLabel =
decisionStatus === REQUEST_STATUS.rejected
? 'Причина отклонения'
: decisionStatus === REQUEST_STATUS.returned
? 'Причина возврата'
: undefined;
if (view === 'approvals' || view === 'execution') return { author: true, status: false, assignee, executed };
if (view === 'rework') return { author: false, status: false, assignee, executed, commentLabel };
if (view === 'own:created') return { author: false, status: !statusFilter, assignee, executed, commentLabel };
// 'all' — заявки всех авторов вперемешку, поэтому нужны и автор, и статус.
if (view === 'all') return { author: true, status: true, assignee, executed, startDate };
// История решений: заявка чужая, поэтому нужен автор. Статус — только если он
// не предопределён самой вкладкой («Отклонённые мной» — это всегда «Отклонено»,
// а вот у согласованных/выполненных заявка могла уйти дальше по маршруту).
return { author: true, status: !decisionStatus, assignee, executed, commentLabel };
}
export interface IRequestsTableViewProps {
context: WebPartContext;
view: ListView;
/** Фильтр по статусу заявки; только для view='own:created'. '' — все статусы. */
statusFilter?: string;
onStatusFilterChange?: (status: string) => void;
/**
* Текущая страница (1-based) и её смена. Страница живёт в маршруте, а не
* внутри компонента: так она попадает в адрес (ссылку можно передать),
* переживает открытие/закрытие заявки и запоминается для этой таблицы
* (см. persistPage в shared/routing.ts).
*/
page: number;
onPageChange: (page: number) => void;
/**
* Текст поиска и его смена. Тоже живёт в маршруте (см. page выше): попадает
* в адрес, переживает открытие заявки и запоминается для этой таблицы.
*/
query: string;
onQueryChange: (query: string) => void;
/** Клик по строке — открыть заявку (в выезжающей панели). */
onOpen: (request: IMyRequest) => void;
/** Только при view='all' — тип заявки, чьи заявки показываются целиком (без фильтра по автору). */
allRequestsType?: IRequestType;
}
function formatDate(iso: string): string {
if (!iso) return '';
const d = new Date(iso);
const dd = d.getDate() < 10 ? `0${d.getDate()}` : `${d.getDate()}`;
const mm = d.getMonth() + 1 < 10 ? `0${d.getMonth() + 1}` : `${d.getMonth() + 1}`;
return `${dd}.${mm}.${d.getFullYear()}`;
}
/** Человек в ячейке — «ФИО · почта»; колонки «Автор» и «Исполнитель». */
function personLabel(name: string, email: string): string {
return [name, email].filter(Boolean).join(' · ') || '—';
}
// Таблица заявок. Клик в ЛЮБОМ месте строки открывает заявку (кликабельна вся
// строка, а не только название), поэтому строка — <tr> с onClick + tabIndex
// (доступ с клавиатуры: Enter/Space).
//
// Набор колонок зависит от раздела и от фильтра — см. columnsFor.
/**
* Можно ли удалить строку прямо из таблицы: своя заявка в статусе «Черновик»
* и форма этого типа есть в портале (чужие типы из approval-route портал не
* открывает и не удаляет).
*/
function canDeleteRow(r: IMyRequest): boolean {
return r.kind === 'own' && isDeletableStatus(r.status) && !!getFormComponent(r.formKey);
}
const Table: React.FC<{
rows: IMyRequest[];
view: ListView;
statusFilter: string;
/** Показывать колонку «Дата выхода» (см. showsStartDate). */
withStartDate: boolean;
sort: ISortState;
onSort: (key: SortKey) => void;
onOpen: (request: IMyRequest) => void;
/** Показать историю согласования заявки (окно, без открытия заявки). */
onShowHistory: (request: IMyRequest) => void;
/** Удалить черновик; задан только там, где в таблице бывают свои черновики. */
onDelete?: (request: IMyRequest) => void;
}> = ({ rows, view, statusFilter, withStartDate, sort, onSort, onOpen, onShowHistory, onDelete }) => {
const cols = columnsFor(view, statusFilter, withStartDate);
const sortable = (label: string, key: SortKey): React.ReactNode => (
<SortableHeader label={label} sortKey={key} sort={sort} onSort={onSort} />
);
// Колонки для DataTable строим динамически: базовые всегда, автор/статус/
// комментарий — по columnsFor (зависит от раздела и фильтра). Кастомные ячейки
// (статус → StatusBadge, дата → формат) задаются через render.
const columns: IDataTableColumn<IMyRequest>[] = [
// Код заявки, а не Id элемента: Id уникален только внутри своей партиции
// (в каждом списке SharePoint нумерация с единицы), поэтому после архивации
// в таблице встречались разные заявки под одним номером. Кода нет у заявок,
// созданных до появления колонки — для них показываем Id, как раньше.
{ 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: sortable('Дата', 'date'), width: '110px', render: (r) => formatDate(r.created) }
];
if (cols.startDate) {
columns.push({
key: 'startDate',
title: 'Дата выхода',
width: '120px',
render: (r) => formatDate(r.startDate ?? '') || '—'
});
}
if (cols.author) {
columns.push({
key: 'author',
title: 'Автор',
width: '280px',
render: (r) => personLabel(r.authorName, r.authorEmail)
});
}
if (cols.status) {
columns.push({
key: 'status',
title: sortable('Статус', 'status'),
width: '190px',
render: (r) => (r.status ? <StatusBadge status={r.status} size="sm" /> : '—')
});
}
if (cols.assignee) {
columns.push({
key: 'assignee',
title: 'Исполнитель',
// Та же ширина, что у «Автора»: содержимое того же вида.
width: '280px',
// «ФИО · почта», как в колонке «Автор». Параллельный шаг — каждый
// человек отдельной строкой.
render: (r) =>
r.currentAssignees && r.currentAssignees.length > 0
? r.currentAssignees.map((a) => (
<div key={`${a.email}|${a.name}`}>{personLabel(a.name, a.email)}</div>
))
: '—'
});
}
// Только если на странице есть хоть одна выполненная часть: у типов заявок
// без фазы исполнения (и на страницах, где до неё ещё не дошло) колонка
// вышла бы из одних прочерков.
if (cols.executed && rows.some((r) => (r.executedSteps ?? []).length > 0)) {
columns.push({
key: 'executed',
title: 'Исполнено',
width: '280px',
// Каждый выполнивший — отдельной строкой: «ФИО · дата время».
render: (r) =>
r.executedSteps && r.executedSteps.length > 0
? r.executedSteps.map((s) => (
<div key={`${s.email}|${s.name}|${s.executedAt}`}>
{[s.name || s.email, s.executedAt ? formatDateTime(s.executedAt) : ''].filter(Boolean).join(' · ')}
</div>
))
: '—'
});
}
if (cols.commentLabel) {
columns.push({
key: 'comment',
title: cols.commentLabel,
render: (r) => r.decisionComment || <span className={styles.commentEmpty}>Без комментария</span>
});
}
// Действия строки: история согласования — у всех, корзина — только у своих
// черновиков. Клик по всей строке открывает заявку — кнопкам это не нужно,
// поэтому stopPropagation.
const hasDeletable = !!onDelete && rows.some(canDeleteRow);
columns.push({
key: 'actions',
title: '',
// Место под одну или две иконки-кнопки (sm) с зазором.
width: hasDeletable ? '92px' : '56px',
render: (r) => (
<div className={styles.rowActions}>
<IconButton
icon="calendar"
title="История согласования"
size="sm"
onClick={(e) => {
e.stopPropagation();
onShowHistory(r);
}}
/>
{onDelete && canDeleteRow(r) && (
<IconButton
icon="trash"
title="Удалить черновик"
danger
size="sm"
onClick={(e) => {
e.stopPropagation();
onDelete(r);
}}
/>
)}
</div>
)
});
return (
<div className={styles.tableWrap}>
<DataTable
columns={columns}
rows={rows}
rowKey={(r) => `${r.requestTypeId}-${r.id}`}
onRowClick={onOpen}
/>
</div>
);
};
// Раздел с таблицей заявок: поиск по всем полям + вывод постранично.
//
// Движок выбирается по наличию текста в поиске, а не по разделу: БЕЗ запроса —
// всегда обычный REST (мгновенно свежий, без задержки индексации), С запросом —
// всегда Search (полнотекстовый, но переиндексируется с задержкой). Раньше
// поиск подмешивал REST для «свежих» строк поверх Search — из-за этого
// результат мог быть неустойчивым (заявка то находилась, то нет, в зависимости
// от того, попала ли она в окно подстраховки); эту гибридную логику убрали.
//
// 'own:created' без фильтра и поиска — getOwnRequests (Search + REST-довесок на
// свежесть). С поиском — searchOwnRequests (Search, RowLimit/StartRow).
//
// Очереди и история решений ('approvals'/'execution'/'own:*' кроме created) без
// поиска — getApprovalQueue: REST по ApproverId(+Status), страница нарезается на
// СЕРВЕРЕ (сам берёт нужный срез индекса и дочитывает автора только для него).
// С поиском — getApprovalQueueIndex даёт ПОЛНЫЙ список этой же очереди без
// автора (постранично его тут не взять, т.к. фильтрация текстом идёт уже
// после), затем текстовый фильтр через Search (filterPendingByQuery), и только
// после фильтрации — нарезка на страницы и дочитывание автора
// (hydratePendingAuthors) — тоже только для видимой страницы.
//
// Отбор по статусу ЗАЯВКИ ('rework' и фильтр на 'own:created') — НЕ через KQL:
// managed property для произвольной Choice-колонки ненадёжна (см.
// getOwnRequestsByStatus в myRequests.service.ts — там объяснение и ссылка на
// реальный инцидент). Вместо этого Search ищет только по автору (надёжная
// AuthorOWSUSER), а статус фильтруется в памяти по полям, дочитанным через
// REST; постраничность там же, в памяти.
//
// Все варианты перечитываются по событию rp-requests-changed
// (shared/requestEvents) — после создания заявки и после решения согласующего.
// Поиск идёт по индексу, поэтому свежая правка может проявиться не мгновенно.
export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
context,
view,
statusFilter = '',
onStatusFilterChange,
page,
onPageChange,
query,
onQueryChange,
onOpen,
allRequestsType,
}) => {
const [types, setTypes] = React.useState<IRequestType[] | undefined>(undefined);
const [rows, setRows] = React.useState<IMyRequest[] | undefined>(undefined);
const [totalRows, setTotalRows] = React.useState<number>(0);
const [error, setError] = React.useState<string>('');
const [deleteError, setDeleteError] = React.useState<string>('');
// Заявка, чья история согласования открыта в окне; undefined — окно закрыто.
const [historyFor, setHistoryFor] = React.useState<IMyRequest | undefined>(undefined);
// Список перечитается сам: confirmAndDeleteDraft шлёт rp-requests-changed.
const handleDelete = (request: IMyRequest): void => {
setDeleteError('');
confirmAndDeleteDraft({
formKey: request.formKey,
itemId: request.id,
listId: request.listId,
code: request.code
}).catch((e) => setDeleteError(`Не удалось удалить черновик: ${friendlyErrorMessage(e)}`));
};
// input — то, что в поле ввода (меняется сразу); в маршрут значение уходит
// после паузы в наборе. Данные грузятся по МАРШРУТНОМУ query — он и так уже
// «отложенный», отдельный debounce здесь был бы вторым по счёту.
const [input, setInput] = useRoutedQuery(query, onQueryChange);
const debouncedQuery = query;
// Уточнён ли список (refinement.isRefined) сверх того, что умеет обычная
// постраничная загрузка. Если да — таблица грузит ПОЛНЫЙ список раздела и сама
// фильтрует, сортирует и режет его на страницы (см. fetchRefinedPage). Если
// нет — всё как раньше.
const refinement = useTableRefinement({
tableKey: `${view}|${allRequestsType?.id ?? ''}`,
storageScope: effectiveUserEmail(context),
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);
// Сколько заявок в разделе БЕЗ фильтров — для «Показано 3 из 10» в строке
// включённых фильтров. undefined — неизвестно (постраничная загрузка или
// статус из маршрута уже сузил список на сервере): тогда пишем просто «Показано 3».
const [unfilteredTotal, setUnfilteredTotal] = React.useState<number | undefined>(undefined);
// Какой ИМЕННО таблице принадлежат лежащие в состоянии rows/totalRows.
// Компонент один на все разделы (см. LIST_VIEWS в RequestPortal), поэтому при
// переключении раздела его состояние ещё хранит данные ПРЕДЫДУЩЕГО, пока не
// отработает загрузка. Без этой отметки сброс страницы ниже принимал решение
// по чужому totalRows и сбивал страницу на первую — из-за чего запомненная
// страница затиралась при каждом переходе по левому меню.
// Страница в ключ НЕ входит: totalRows один на всю таблицу, а не на страницу.
const dataKey = `${view}|${statusFilter}|${debouncedQuery.trim()}|${allRequestsType?.id ?? ''}|${refinementSig}`;
const [loadedKey, setLoadedKey] = React.useState<string>('');
// Страницу при смене запроса здесь НЕ сбрасываем: и запрос, и страница живут
// в маршруте, и меняются они одним переходом (см. setQuery в RequestPortal).
React.useEffect(() => {
let cancelled = false;
getLinkedTypes(context)
.then((t) => {
if (!cancelled) setTypes(t);
})
.catch((e) => {
if (!cancelled) setError((e as Error).message);
});
return () => {
cancelled = true;
};
}, [context]);
React.useEffect(() => {
if (types === undefined) return;
let cancelled = false;
const cacheKey = pageKey('table', [
view,
statusFilter,
debouncedQuery.trim(),
page,
allRequestsType?.id ?? 0,
refinementSig,
]);
// Сразу показываем то, что уже загружали для ЭТОЙ вкладки (и этого запроса
// и страницы), а обновление идёт фоном. Компонент один на обе вкладки —
// без кеша при переключении на экране оставались бы строки предыдущей
// вкладки, пока не придёт ответ. Если для этой вкладки данных ещё нет —
// rows становится undefined, и показывается «Загрузка…».
const cached = readPage(cacheKey);
setRows(cached ? cached.rows : undefined);
setTotalRows(cached ? cached.totalRows : 0);
// Пока не подтвердим, что загруженное относится к ТЕКУЩЕЙ таблице, ничего
// на основании totalRows решать нельзя — см. loadedKey ниже.
setLoadedKey(cached ? dataKey : '');
const q = debouncedQuery.trim();
// onPartial — Search и REST-довесок по свежести («Мои заявки»/«Доработка»,
// см. getOwnRequests/getOwnRequestsByStatus в myRequests.service.ts) теперь
// идут параллельно, и заранее не угадать, какой из двух ответит первым —
// сеть шумит то в одну, то в другую сторону. onPartial зовётся с тем, что
// пришло раньше, чтобы не держать список пустым ради заведомо более
// медленного источника. Остальные ветки (approvals/execution/поиск) его
// просто не принимают — там нет пары «Search + довесок» с независимо
// валидным промежуточным результатом.
// ПОЛНЫЙ список раздела (с учётом поиска и статуса из маршрута), без нарезки
// на страницы — сырьё для фильтров и сортировки. Источники те же, что у
// постраничной загрузки ниже, только без 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)) {
const status = view === 'rework' ? REQUEST_STATUS.returned : statusFilter;
return getOwnRequestsByStatus(context, types, status, page, debouncedQuery, onPartial);
}
if (view === 'own:created') {
return q.length > 0
? searchOwnRequests(context, types, debouncedQuery, page)
: getOwnRequests(context, types, page, onPartial);
}
if (view === 'all') {
if (!allRequestsType) return { rows: [], totalRows: 0 };
return getAllRequestsForType(context, allRequestsType, page, debouncedQuery);
}
const queue = QUEUE_BY_VIEW[view];
if (q.length === 0) {
// Без поиска — страница уже нарезана и дочитана сервером.
const r = await getApprovalQueue(context, queue, page);
return { rows: r.rows, totalRows: r.totalRows };
}
// С поиском — сначала полный список этой очереди (без автора), фильтруем
// текстом, и только потом режем на страницы и дочитываем автора — только
// для видимой страницы.
const idx = await getApprovalQueueIndex(context, queue);
const filtered = await filterPendingByQuery(context, idx.types, idx.rows, debouncedQuery);
const pageRows = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
await hydratePendingAuthors(context, idx.types, pageRows);
return { rows: pageRows, totalRows: filtered.length };
};
// Комментарий к решению дочитываем ОДНИМ местом на все ветки: «Доработка» и
// «Отклонённые мной» приходят разными путями (по автору и по строкам
// маршрута соответственно), а колонка у них одинаковая по смыслу. И только
// для видимой страницы — как и автор в hydratePendingAuthors. Промежуточный
// показ (onPartial) комментарий ещё не несёт — он дочитывается только для
// ИТОГОВОГО результата, полный показ его донесёт следом.
const fetchPage = async (onPartial?: (p: IPage) => void, reuseFull = false): Promise<IPage> => {
const result = await fetchRows(onPartial, reuseFull);
const decisionStatus = decisionStatusFor(view, statusFilter);
// Оба дочитывания из строк маршрута независимы — параллельно.
await Promise.all([
decisionStatus ? hydrateDecisionComments(context, types, result.rows, decisionStatus) : undefined,
showsAssignee(view, statusFilter) ? hydrateCurrentAssignees(context, types, result.rows) : undefined,
showsExecuted(view, statusFilter) ? hydrateExecutedSteps(context, types, result.rows) : undefined,
showsStartDate(view, allRequestsType) ? hydrateStartDates(context, result.rows) : undefined,
]);
return result;
};
// reuseFull — только для первой загрузки эффекта (см. fetchRefinedPage):
// автообновление и rp-requests-changed обязаны читать данные заново.
const load = async (forceRender = false, reuseFull = false): Promise<void> => {
try {
// Промежуточный показ — ТОЛЬКО когда для этой страницы прямо СЕЙЧАС
// ничего не закешировано (первый заход, экран пуст). Проверяем на
// каждый вызов load(), а не один раз на весь эффект: тот же load()
// переиспользуется автообновлением по таймеру и событием
// rp-requests-changed все 10 минут жизни вкладки, и ко второму вызову
// кеш почти наверняка уже есть — без повторной проверки список бы на
// мгновение "усыхал" при КАЖДОМ фоновом обновлении, а не только при
// самой первой загрузке.
const onPartial = readPage(cacheKey)
? undefined
: (partial: IPage): void => {
if (cancelled) return;
// ПУСТОЙ промежуточный результат не показываем: смысл onPartial —
// не держать экран пустым, пока отвечает более медленный источник,
// а пустая страница как раз пустой экран и даёт, только вместо
// «Загрузка…» на нём появляется «Вы ещё не создавали заявок» —
// и через мгновение исчезает, когда придёт второй источник.
if (partial.rows.length === 0) return;
setRows(partial.rows);
setTotalRows(partial.totalRows);
};
const fresh = await perfScope(`таблица ${view} стр.${page}`, () => fetchPage(onPartial, reuseFull));
if (cancelled) return;
// Перерисовываем, только если данные реально изменились — иначе при
// каждом возврате на вкладку таблица моргала бы, перерисовываясь тем же
// самым содержимым. forceRender — исключение: строки уже спрятаны со
// экрана (см. force ниже), и их обязательно нужно вернуть, даже если
// свежий ответ побайтово совпал с тем, что было в кеше, — иначе таблица
// так и останется в состоянии «Загрузка…», хотя данные давно пришли
// (баг: ушёл на этом самом совпадении сигнатур, найден 2026-08-18).
const prev = readPage(cacheKey);
if (forceRender || !prev || pageSignature(prev) !== pageSignature(fresh)) {
writePage(cacheKey, fresh);
setRows(fresh.rows);
setTotalRows(fresh.totalRows);
}
const full = fullListRef.current;
const narrowedByRouteStatus = view === 'own:created' && !!statusFilter;
setUnfilteredTotal(
isRefined && !narrowedByRouteStatus && full && full.key === fullKey ? full.rows.length : undefined
);
// Данные точно от текущей таблицы — теперь по ним можно судить о
// количестве страниц (см. сброс страницы ниже).
setLoadedKey(dataKey);
setError('');
} catch (e) {
if (!cancelled) setError((e as Error).message);
}
};
load(false, true).catch(() => undefined); // ошибки уже обработаны внутри load через setError
const unsubscribe = onRequestsChanged((force) => {
// Явный повторный клик по уже открытому пункту меню — пользователь ждёт
// видимого обновления, а не молчаливой подмены данных за спиной. Прежние
// строки прячем сразу; свежие подставит load(force).
if (force) {
setRows(undefined);
setLoadedKey('');
}
load(force).catch(() => undefined);
});
// Пока вкладка браузера скрыта, обновляться незачем — при возврате к ней
// сработает ближайший тик.
const timer = window.setInterval(() => {
if (!document.hidden) load().catch(() => undefined);
}, AUTO_REFRESH_MS);
return () => {
cancelled = true;
unsubscribe();
window.clearInterval(timer);
};
// localFilters пересобирается на каждый рендер (см. выше) — в зависимостях его
// заменяет refinementSig, по которому он и меняется.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [context, types, view, statusFilter, debouncedQuery, page, dataKey, allRequestsType, isRefined, refinementSig]);
// Все ветки отдают уже нарезанную страницу — просто показываем как есть.
const q = debouncedQuery.trim();
const total = totalRows;
const visible = rows || [];
/**
* Можно ли уже судить о содержимом таблицы.
*
* Пустоту («Вы ещё не создавали заявок») объявляем ТОЛЬКО по завершённой
* загрузке именно ЭТОЙ таблицы (loadedKey), а не по любому пустому rows: до
* этого rows может быть пустым по совершенно другой причине — часть источников
* ещё в пути (см. onPartial выше, «Мои заявки» собираются из Search + REST).
* Промежуточный НЕПУСТОЙ результат показывать можно и нужно — в нём уже есть
* что смотреть.
*/
const loaded = loadedKey === dataKey || visible.length > 0;
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
// Страница может оказаться за пределами набора: пришла из старой ссылки или
// из запомненной, а заявок с тех пор стало меньше (часть согласовали, и они
// ушли из этой очереди). Показывать пустую таблицу с непустым счётчиком —
// выглядит как поломка, поэтому возвращаемся на последнюю существующую.
//
// Строго ПОСЛЕ проверки loadedKey: totalRows в состоянии может быть ещё от
// предыдущей таблицы (компонент общий), и решение по нему сбило бы страницу
// на первую при обычном переходе по левому меню.
React.useEffect(() => {
if (loadedKey !== dataKey) return;
if (rows && page > totalPages) onPageChange(totalPages);
}, [loadedKey, dataKey, rows, page, totalPages, onPageChange]);
// Запомненный тип заявки, которого больше нет среди доступных пользователю
// (тип убрали или доступ к нему отозвали), — снимаем: иначе таблица навсегда
// осталась бы пустой по фильтру, который в выпадающем списке даже не выбрать.
const storedTypeId = refinement.barFilters.typeId;
React.useEffect(() => {
if (view === 'all' || !types || storedTypeId === undefined) return;
if (types.some((t) => t.id === storedTypeId)) return;
refinement.onFiltersChange({ ...refinement.barFilters, typeId: undefined });
// refinement пересобирается на каждый рендер — реагируем только на сам тип и справочник.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [view, types, storedTypeId]);
return (
<section className={styles.view}>
<div className={styles.head}>
<h2 className={styles.heading}>
{view === 'all' && allRequestsType ? `Все заявки: ${allRequestsType.name}` : HEADINGS[view]}
{rows && <span className={styles.count}>{total}</span>}
</h2>
<input
type="search"
className={styles.search}
placeholder="Поиск по заявкам…"
value={input}
onChange={(e) => setInput(e.target.value)}
/>
</div>
<RequestsFilterBar
filters={refinement.barFilters}
onChange={refinement.onFiltersChange}
// «Все заявки» уже показывают один выбранный тип — фильтр по типу там пуст.
types={view === 'all' ? undefined : types}
// Статус — там же, где его колонка: в остальных разделах он у всех строк один.
showStatus={columnsFor(view, '').status}
// При включённых фильтрах сброс — в строке ActiveFiltersBar («Сбросить все»);
// здесь кнопка остаётся только для случая «изменена одна сортировка».
canReset={refinement.canReset && !hasActiveFilters(refinement.barFilters)}
onReset={refinement.onReset}
/>
<ActiveFiltersBar
filters={refinement.barFilters}
types={types}
onChange={refinement.onFiltersChange}
onReset={refinement.onReset}
shown={loaded ? total : undefined}
total={unfilteredTotal}
/>
{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}» ничего не найдено.`
: hasActiveFilters(refinement.barFilters)
? NO_MATCHES_TEXT
: EMPTY_TEXTS[view]}
</div>
)}
{!error && rows && total > 0 && (
<>
<Table
rows={visible}
view={view}
statusFilter={statusFilter}
withStartDate={showsStartDate(view, allRequestsType)}
sort={sort}
onSort={refinement.onSort}
onOpen={onOpen}
onShowHistory={setHistoryFor}
onDelete={view === 'own:created' ? handleDelete : undefined}
/>
<Pager page={page} totalPages={totalPages} onChange={onPageChange} />
</>
)}
<RequestHistoryDialog request={historyFor} onClose={() => setHistoryFor(undefined)} />
</section>
);
};