Search requests (#2)

Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/timur.gorodetskiy/request-portal/pulls/2
Co-authored-by: timur.gorodetskiy <timur.gorodetskiy@tnsu.kz>
Co-committed-by: timur.gorodetskiy <timur.gorodetskiy@tnsu.kz>
This commit was merged in pull request #2.
This commit is contained in:
2026-08-06 09:59:07 +00:00
committed by gorbenko.pavel
parent 571614c031
commit 6acb22dd95
23 changed files with 1622 additions and 459 deletions
@@ -1,9 +1,20 @@
import * as React from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import { getLinkedTypes, getOwnRequests, getPendingApproval } from './myRequests.service';
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;
@@ -14,6 +25,10 @@ export interface IRequestsTableViewProps {
kind: 'approvals' | 'own';
/** Клик по строке — открыть заявку (в выезжающей панели). */
onOpen: (request: IMyRequest) => void;
/** Точное число «ждут решения СЕЙЧАС» (kind='approvals', без поиска) — для
* счётчика в SideNav, чтобы не гонять для него отдельный параллельный
* запрос, который мог бы разойтись по времени с тем, что видно в таблице. */
onMyTurnCountChange?: (count: number) => void;
}
function formatDate(iso: string): string {
@@ -41,70 +56,6 @@ function statusClass(status: string): string {
}
}
// Постраничная навигация: номера страниц + вперёд/назад. При большом
// количестве страниц показывает только соседние с текущей + первую/последнюю.
const Pager: React.FC<{ page: number; totalPages: number; onChange: (page: number) => void }> = ({
page,
totalPages,
onChange,
}) => {
if (totalPages <= 1) return null;
const numbers: number[] = [];
const from = Math.max(1, page - 2);
const to = Math.min(totalPages, page + 2);
for (let i = from; i <= to; i++) numbers.push(i);
return (
<div className={styles.pager}>
<button
type="button"
className={styles.pagerBtn}
disabled={page <= 1}
onClick={() => onChange(page - 1)}
aria-label="Предыдущая страница"
>
‹
</button>
{from > 1 && (
<>
<button type="button" className={styles.pagerBtn} onClick={() => onChange(1)}>
1
</button>
{from > 2 && <span className={styles.pagerEllipsis}>…</span>}
</>
)}
{numbers.map((n) => (
<button
key={n}
type="button"
className={`${styles.pagerBtn} ${n === page ? styles.pagerBtnActive : ''}`}
onClick={() => onChange(n)}
>
{n}
</button>
))}
{to < totalPages && (
<>
{to < totalPages - 1 && <span className={styles.pagerEllipsis}>…</span>}
<button type="button" className={styles.pagerBtn} onClick={() => onChange(totalPages)}>
{totalPages}
</button>
</>
)}
<button
type="button"
className={styles.pagerBtn}
disabled={page >= totalPages}
onClick={() => onChange(page + 1)}
aria-label="Следующая страница"
>
›
</button>
</div>
);
};
// Таблица заявок. Клик в ЛЮБОМ месте строки открывает заявку (кликабельна вся
// строка, а не только название), поэтому строка — <tr> с onClick + tabIndex
// (доступ с клавиатуры: Enter/Space).
@@ -166,24 +117,49 @@ const Table: React.FC<{
</div>
);
// Раздел с таблицей заявок: поиск по названию + постраничный вывод.
// Раздел с таблицей заявок: поиск по всем полям + вывод постранично.
//
// 'approvals' грузится целиком (алгоритм «чья сейчас очередь» требует видеть
// весь маршрут заявки), поиск и пагинация — на клиенте.
// 'own' идёт постранично через обычный REST (см. myRequests.service), поиск —
// серверным OData-фильтром по Title.
// Движок выбирается по наличию текста в поиске, а не по разделу: БЕЗ запроса —
// всегда обычный 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<IRequestsTableViewProps> = ({ context, kind, onOpen }) => {
// Поиск идёт по индексу, поэтому свежая правка может проявиться не мгновенно.
export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
context,
kind,
onOpen,
onMyTurnCountChange,
}) => {
const [types, setTypes] = React.useState<IRequestType[] | undefined>(undefined);
const [rows, setRows] = React.useState<IMyRequest[] | undefined>(undefined);
const [totalRows, setTotalRows] = React.useState<number>(0);
const [page, setPage] = React.useState<number>(1);
const [query, setQuery] = React.useState<string>('');
const [query, setQuery] = usePersistedQuery(kind);
const [error, setError] = React.useState<string>('');
// query — то, что в поле ввода (меняется сразу); debouncedQuery — то, что
// уходит в запрос (после паузы в наборе). Всё ниже завязано на debouncedQuery,
// иначе поиск бил бы запросом по каждой набранной букве.
const debouncedQuery = useDebouncedQuery(query);
React.useEffect(() => setPage(1), [query, kind]);
React.useEffect(() => setPage(1), [debouncedQuery, kind]);
React.useEffect(() => {
let cancelled = false;
@@ -202,45 +178,77 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({ context,
React.useEffect(() => {
if (types === undefined) return;
let cancelled = false;
const cacheKey = pageKey('table', [kind, debouncedQuery.trim(), page]);
const load = (): void => {
const request =
kind === 'own'
? getOwnRequests(context, types, query, page).then((r) => ({ rows: r.rows, total: r.totalRows }))
: getPendingApproval(context).then((r) => {
console.table(r.debug);
return { rows: r.rows, total: r.rows.length };
});
// Сразу показываем то, что уже загружали для ЭТОЙ вкладки (и этого запроса
// и страницы), а обновление идёт фоном. Компонент один на обе вкладки —
// без кеша при переключении на экране оставались бы строки предыдущей
// вкладки, пока не придёт ответ. Если для этой вкладки данных ещё нет —
// rows становится undefined, и показывается «Загрузка…».
const cached = readPage(cacheKey);
setRows(cached ? cached.rows : undefined);
setTotalRows(cached ? cached.totalRows : 0);
request
.then((result) => {
if (cancelled) return;
setRows(result.rows);
setTotalRows(result.total);
setError('');
})
.catch((e) => {
if (!cancelled) setError((e as Error).message);
});
const fetchPage = async (): Promise<IPage> => {
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 };
};
load();
const unsubscribe = onRequestsChanged(load);
const load = async (): Promise<void> => {
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);
});
return () => {
cancelled = true;
unsubscribe();
};
}, [context, types, kind, query, page]);
}, [context, types, kind, debouncedQuery, page]);
// 'approvals' приходит целиком — фильтруем и нарезаем страницы на клиенте.
const q = query.trim().toLowerCase();
let visible = rows || [];
let total = totalRows;
if (kind === 'approvals' && rows) {
const filtered = q ? rows.filter((r) => r.title.toLowerCase().indexOf(q) !== -1) : rows;
total = filtered.length;
visible = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
}
// Обе ветки теперь отдают уже нарезанную страницу — просто показываем как есть.
const q = debouncedQuery.trim();
const total = totalRows;
const visible = rows || [];
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const heading = kind === 'approvals' ? 'Ждут вашего решения' : 'Мои заявки';
@@ -255,7 +263,7 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({ context,
<input
type="search"
className={styles.search}
placeholder="Поиск по названию…"
placeholder="Поиск по заявкам…"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
@@ -266,7 +274,7 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({ context,
{!error && rows && total === 0 && (
<div className={styles.empty}>
{q
? `По запросу «${query}» ничего не найдено.`
? `По запросу «${debouncedQuery}» ничего не найдено.`
: kind === 'approvals'
? 'Нет заявок, ждущих вашего решения.'
: 'Вы ещё не создавали заявок.'}