import * as React from 'react'; import type { WebPartContext } from '@microsoft/sp-webpart-base'; import { filterPendingByQuery, getLinkedTypes, getOwnRequests, getPendingApproval, getPendingApprovalIndex, hydratePendingAuthors, searchOwnRequests, } from './myRequests.service'; import { IMyRequest } from './models/IMyRequest'; import { REQUEST_STATUS, IRequestType } from '../../shared/approval'; import { onRequestsChanged } from '../../shared/requestEvents'; import { usePersistedQuery, useDebouncedQuery } from '../../shared/usePersistedQuery'; import { IPage, pageKey, pageSignature, readPage, writePage } from './pageCache'; import { Pager } from './Pager'; import styles from './RequestsTableView.module.scss'; const PAGE_SIZE = 20; // Автообновление открытых таблиц: раз в 10 минут перечитываем данные, чтобы // вкладка, оставленная открытой надолго, не показывала устаревший список. // Обновление фоновое — строки с экрана не пропадают и перерисовываются, только // если данные реально изменились (см. pageSignature). const AUTO_REFRESH_MS = 10 * 60 * 1000; export interface IRequestsTableViewProps { context: WebPartContext; /** 'approvals' — ждут решения текущего пользователя; 'own' — созданные им. */ kind: 'approvals' | 'own'; /** Клик по строке — открыть заявку (в выезжающей панели). */ onOpen: (request: IMyRequest) => void; /** Точное число «ждут решения СЕЙЧАС» (kind='approvals', без поиска) — для * счётчика в SideNav, чтобы не гонять для него отдельный параллельный * запрос, который мог бы разойтись по времени с тем, что видно в таблице. */ onMyTurnCountChange?: (count: number) => void; } 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 statusClass(status: string): string { switch (status) { case REQUEST_STATUS.approved: return styles.statusApproved; case REQUEST_STATUS.rejected: return styles.statusRejected; case REQUEST_STATUS.returned: return styles.statusReturned; case REQUEST_STATUS.onApproval: return styles.statusOnApproval; case REQUEST_STATUS.draft: return styles.statusDraft; default: return styles.statusDefault; } } // Таблица заявок. Клик в ЛЮБОМ месте строки открывает заявку (кликабельна вся // строка, а не только название), поэтому строка — с onClick + tabIndex // (доступ с клавиатуры: Enter/Space). // // «approvals»: автор — не текущий пользователь, поэтому нужна колонка «Автор»; // статус не показываем — здесь все заявки по определению на согласовании. // «own»: автор всегда текущий пользователь, зато важен статус. const Table: React.FC<{ rows: IMyRequest[]; kind: 'approvals' | 'own'; onOpen: (request: IMyRequest) => void; }> = ({ rows, kind, onOpen }) => (
{kind === 'approvals' && } {kind === 'own' && } {rows.map((r) => ( onOpen(r)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onOpen(r); } }} > {kind === 'approvals' && ( )} {kind === 'own' && ( )} ))}
ID Тип Название ДатаАвторСтатус
{r.id} {r.requestTypeName} {r.title || `#${r.id}`} {formatDate(r.created)} {[r.authorName, r.authorEmail].filter(Boolean).join(' · ') || '—'} {r.status || '—'}
); // Раздел с таблицей заявок: поиск по всем полям + вывод постранично. // // Движок выбирается по наличию текста в поиске, а не по разделу: БЕЗ запроса — // всегда обычный REST (мгновенно свежий, без задержки индексации), С запросом — // всегда Search (полнотекстовый, но переиндексируется с задержкой). Раньше // поиск подмешивал REST для «свежих» строк поверх Search — из-за этого // результат мог быть неустойчивым (заявка то находилась, то нет, в зависимости // от того, попала ли она в окно подстраховки); эту гибридную логику убрали. // // 'own' без поиска — getOwnRequests (REST по AuthorId, страница режется в // памяти). 'own' с поиском — searchOwnRequests (Search, RowLimit/StartRow). // // 'approvals' без поиска — getPendingApproval, REST по ApproverId(+Status), // страница нарезается на СЕРВЕРЕ (сам берёт нужный срез индекса и дочитывает // автора только для него). // 'approvals' с поиском — область расширяется до «где пользователь вообще есть // в маршруте» (getPendingApprovalIndex, ПОЛНЫЙ список без автора — постранично // его тут не взять, т.к. фильтрация текстом идёт уже после), затем текстовый // фильтр через Search (filterPendingByQuery), и только после фильтрации — // нарезка на страницы и дочитывание автора (hydratePendingAuthors) — тоже // только для видимой страницы, а не для всего отфильтрованного набора. // // Оба варианта перечитываются по событию rp-requests-changed // (shared/requestEvents) — после создания заявки и после решения согласующего. // Поиск идёт по индексу, поэтому свежая правка может проявиться не мгновенно. export const RequestsTableView: React.FC = ({ context, kind, onOpen, onMyTurnCountChange, }) => { const [types, setTypes] = React.useState(undefined); const [rows, setRows] = React.useState(undefined); const [totalRows, setTotalRows] = React.useState(0); const [page, setPage] = React.useState(1); const [query, setQuery] = usePersistedQuery(kind); const [error, setError] = React.useState(''); // query — то, что в поле ввода (меняется сразу); debouncedQuery — то, что // уходит в запрос (после паузы в наборе). Всё ниже завязано на debouncedQuery, // иначе поиск бил бы запросом по каждой набранной букве. const debouncedQuery = useDebouncedQuery(query); React.useEffect(() => setPage(1), [debouncedQuery, kind]); 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', [kind, debouncedQuery.trim(), page]); // Сразу показываем то, что уже загружали для ЭТОЙ вкладки (и этого запроса // и страницы), а обновление идёт фоном. Компонент один на обе вкладки — // без кеша при переключении на экране оставались бы строки предыдущей // вкладки, пока не придёт ответ. Если для этой вкладки данных ещё нет — // rows становится undefined, и показывается «Загрузка…». const cached = readPage(cacheKey); setRows(cached ? cached.rows : undefined); setTotalRows(cached ? cached.totalRows : 0); const fetchPage = async (): Promise => { if (kind === 'own') { return debouncedQuery.trim().length > 0 ? searchOwnRequests(context, types, debouncedQuery, page) : getOwnRequests(context, types, page); } if (debouncedQuery.trim().length === 0) { // Без поиска — страница уже нарезана и дочитана сервером. const r = await getPendingApproval(context, 'myTurn', page); return { rows: r.rows, totalRows: r.totalRows }; } // С поиском — сначала полный список «где я есть в маршруте» (без // автора), фильтруем текстом, и только потом режем на страницы и // дочитываем автора — только для видимой страницы. const idx = await getPendingApprovalIndex(context, 'involved'); 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 }; }; const load = async (): Promise => { try { const fresh = await fetchPage(); if (cancelled) return; // Перерисовываем, только если данные реально изменились — иначе при // каждом возврате на вкладку таблица моргала бы, перерисовываясь тем же // самым содержимым. const prev = readPage(cacheKey); if (!prev || pageSignature(prev) !== pageSignature(fresh)) { writePage(cacheKey, fresh); setRows(fresh.rows); setTotalRows(fresh.totalRows); } setError(''); if (onMyTurnCountChange && kind === 'approvals' && debouncedQuery.trim().length === 0) { onMyTurnCountChange(fresh.totalRows); } } catch (e) { if (!cancelled) setError((e as Error).message); } }; load().catch(() => undefined); // ошибки уже обработаны внутри load через setError const unsubscribe = onRequestsChanged(() => { load().catch(() => undefined); }); // Пока вкладка браузера скрыта, обновляться незачем — при возврате к ней // сработает ближайший тик. const timer = window.setInterval(() => { if (!document.hidden) load().catch(() => undefined); }, AUTO_REFRESH_MS); return () => { cancelled = true; unsubscribe(); window.clearInterval(timer); }; }, [context, types, kind, debouncedQuery, page]); // Обе ветки теперь отдают уже нарезанную страницу — просто показываем как есть. const q = debouncedQuery.trim(); const total = totalRows; const visible = rows || []; const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); const heading = kind === 'approvals' ? 'Ждут вашего решения' : 'Мои заявки'; return (

{heading} {rows && {total}}

setQuery(e.target.value)} />
{error &&
Не удалось загрузить заявки: {error}
} {!error && rows === undefined &&
Загрузка…
} {!error && rows && total === 0 && (
{q ? `По запросу «${debouncedQuery}» ничего не найдено.` : kind === 'approvals' ? 'Нет заявок, ждущих вашего решения.' : 'Вы ещё не создавали заявок.'}
)} {!error && rows && total > 0 && ( <> )} ); };