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 = { approvals: 'Ждут вашего согласования', execution: 'Ждут вашего исполнения', rework: 'Возвращены вам на доработку', 'own:created': 'Инициированные мной', 'own:approved': 'Согласованные мной', 'own:rejected': 'Отклонённые мной', 'own:executed': 'Выполненные мной', all: 'Все заявки', }; const EMPTY_TEXTS: Record = { 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(' · ') || '—'; } // Таблица заявок. Клик в ЛЮБОМ месте строки открывает заявку (кликабельна вся // строка, а не только название), поэтому строка — с 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 => ( ); // Колонки для DataTable строим динамически: базовые всегда, автор/статус/ // комментарий — по columnsFor (зависит от раздела и фильтра). Кастомные ячейки // (статус → StatusBadge, дата → формат) задаются через render. const columns: IDataTableColumn[] = [ // Код заявки, а не 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 ? : '—') }); } if (cols.assignee) { columns.push({ key: 'assignee', title: 'Исполнитель', // Та же ширина, что у «Автора»: содержимое того же вида. width: '280px', // «ФИО · почта», как в колонке «Автор». Параллельный шаг — каждый // человек отдельной строкой. render: (r) => r.currentAssignees && r.currentAssignees.length > 0 ? r.currentAssignees.map((a) => (
{personLabel(a.name, a.email)}
)) : '—' }); } // Только если на странице есть хоть одна выполненная часть: у типов заявок // без фазы исполнения (и на страницах, где до неё ещё не дошло) колонка // вышла бы из одних прочерков. 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) => (
{[s.name || s.email, s.executedAt ? formatDateTime(s.executedAt) : ''].filter(Boolean).join(' · ')}
)) : '—' }); } if (cols.commentLabel) { columns.push({ key: 'comment', title: cols.commentLabel, render: (r) => r.decisionComment || Без комментария }); } // Действия строки: история согласования — у всех, корзина — только у своих // черновиков. Клик по всей строке открывает заявку — кнопкам это не нужно, // поэтому stopPropagation. const hasDeletable = !!onDelete && rows.some(canDeleteRow); columns.push({ key: 'actions', title: '', // Место под одну или две иконки-кнопки (sm) с зазором. width: hasDeletable ? '92px' : '56px', render: (r) => (
{ e.stopPropagation(); onShowHistory(r); }} /> {onDelete && canDeleteRow(r) && ( { e.stopPropagation(); onDelete(r); }} /> )}
) }); return (
`${r.requestTypeId}-${r.id}`} onRowClick={onOpen} />
); }; // Раздел с таблицей заявок: поиск по всем полям + вывод постранично. // // Движок выбирается по наличию текста в поиске, а не по разделу: БЕЗ запроса — // всегда обычный 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 = ({ context, view, statusFilter = '', onStatusFilterChange, page, onPageChange, query, onQueryChange, onOpen, allRequestsType, }) => { const [types, setTypes] = React.useState(undefined); const [rows, setRows] = React.useState(undefined); const [totalRows, setTotalRows] = React.useState(0); const [error, setError] = React.useState(''); const [deleteError, setDeleteError] = React.useState(''); // Заявка, чья история согласования открыта в окне; undefined — окно закрыто. const [historyFor, setHistoryFor] = React.useState(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(undefined); // Какой ИМЕННО таблице принадлежат лежащие в состоянии rows/totalRows. // Компонент один на все разделы (см. LIST_VIEWS в RequestPortal), поэтому при // переключении раздела его состояние ещё хранит данные ПРЕДЫДУЩЕГО, пока не // отработает загрузка. Без этой отметки сброс страницы ниже принимал решение // по чужому totalRows и сбивал страницу на первую — из-за чего запомненная // страница затиралась при каждом переходе по левому меню. // Страница в ключ НЕ входит: totalRows один на всю таблицу, а не на страницу. const dataKey = `${view}|${statusFilter}|${debouncedQuery.trim()}|${allRequestsType?.id ?? ''}|${refinementSig}`; const [loadedKey, setLoadedKey] = React.useState(''); // Страницу при смене запроса здесь НЕ сбрасываем: и запрос, и страница живут // в маршруте, и меняются они одним переходом (см. 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 => { 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 => { const rows = await fetchFullRows(); fullListRef.current = { key: fullKey, rows }; return rows; }; const fetchRefinedPage = async (reuseFull: boolean): Promise => { 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 => { 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 => { 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 => { 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 (

{view === 'all' && allRequestsType ? `Все заявки: ${allRequestsType.name}` : HEADINGS[view]} {rows && {total}}

setInput(e.target.value)} />
{error &&
Не удалось загрузить заявки: {error}
} {deleteError &&
{deleteError}
} {!error && !loaded &&
Загрузка…
} {!error && loaded && total === 0 && (
{q ? `По запросу «${debouncedQuery}» ничего не найдено.` : hasActiveFilters(refinement.barFilters) ? NO_MATCHES_TEXT : EMPTY_TEXTS[view]}
)} {!error && rows && total > 0 && ( <> )} setHistoryFor(undefined)} /> ); };