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
@@ -0,0 +1,212 @@
import * as React from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import { getLinkedTypes, searchMyRequests } 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 { pageKey, pageSignature, readPage, writePage } from './pageCache';
import { Pager } from './Pager';
import styles from './SearchView.module.scss';
const PAGE_SIZE = 20;
export interface ISearchViewProps {
context: WebPartContext;
/** Клик по строке — открыть заявку (в выезжающей панели). */
onOpen: (request: IMyRequest) => 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;
}
}
// В отличие от RequestsTableView (где по смыслу раздела нужно либо Автор,
// либо Статус) — здесь смешаны «свои» и «где участвовал» заявки, поэтому
// показываем обе колонки сразу, плюс небольшая метка «Автор/Согласование» —
// чтобы было видно, откуда строка (kind), не только по автору.
const Table: React.FC<{ rows: IMyRequest[]; onOpen: (request: IMyRequest) => void }> = ({ rows, onOpen }) => (
<div className={styles.tableWrap}>
<table className={styles.table}>
<thead>
<tr>
<th className={styles.colId}>ID</th>
<th className={styles.colType}>Тип</th>
<th className={styles.colTitle}>Название</th>
<th className={styles.colDate}>Дата</th>
<th className={styles.colAuthor}>Автор</th>
<th className={styles.colStatus}>Статус</th>
<th className={styles.colKind}>Роль</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr
className={styles.row}
key={`${r.requestTypeId}-${r.id}`}
tabIndex={0}
role="button"
aria-label={`Открыть заявку «${r.title || r.id}»`}
onClick={() => onOpen(r)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onOpen(r);
}
}}
>
<td className={styles.colId}>{r.id}</td>
<td className={styles.colType}>{r.requestTypeName}</td>
<td className={styles.colTitle}>{r.title || `#${r.id}`}</td>
<td className={styles.colDate}>{formatDate(r.created)}</td>
<td className={styles.colAuthor}>
{[r.authorName, r.authorEmail].filter(Boolean).join(' · ') || '—'}
</td>
<td className={styles.colStatus}>
<span className={`${styles.status} ${statusClass(r.status)}`}>{r.status || '—'}</span>
</td>
<td className={styles.colKind}>{r.kind === 'own' ? 'Создал(а) я' : 'Участвую'}</td>
</tr>
))}
</tbody>
</table>
</div>
);
// Вкладка «Поиск»: ищет сразу и среди своих заявок, и среди тех, где
// пользователь участвует как согласующий (независимо от того, дошла ли уже
// очередь) — единым списком, с колонками Автор и Статус одновременно (см.
// searchMyRequests в myRequests.service.ts). Без введённого текста — пусто,
// это не список «на экран по умолчанию», а именно поиск.
//
// Перечитывается по событию rp-requests-changed (после создания заявки и
// после решения согласующего) — но т.к. это по большей части Search, свежая
// правка может проявиться не мгновенно (частично компенсируется довеском
// последних 10 минут через REST — см. myRequests.service.ts).
export const SearchView: React.FC<ISearchViewProps> = ({ context, onOpen }) => {
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] = usePersistedQuery('search');
// query — то, что в поле ввода (меняется сразу); debouncedQuery — то, что
// уходит в запрос (после паузы в наборе). Здесь это особенно важно: поиск
// ходит сразу по двум источникам (свои заявки + где участвую), то есть
// каждая набранная буква стоила бы двух серий запросов.
const debouncedQuery = useDebouncedQuery(query);
const [error, setError] = React.useState<string>('');
React.useEffect(() => setPage(1), [debouncedQuery]);
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;
if (debouncedQuery.trim().length === 0) {
setRows(undefined);
setTotalRows(0);
return;
}
let cancelled = false;
const cacheKey = pageKey('search', [debouncedQuery.trim(), page]);
// Уже искали это раньше — показываем прежний результат сразу, обновление
// идёт фоном (см. RequestsTableView, там та же схема и те же причины).
const cached = readPage(cacheKey);
setRows(cached ? cached.rows : undefined);
setTotalRows(cached ? cached.totalRows : 0);
const load = async (): Promise<void> => {
try {
const fresh = await searchMyRequests(context, types, debouncedQuery, page);
if (cancelled) return;
const prev = readPage(cacheKey);
if (!prev || pageSignature(prev) !== pageSignature(fresh)) {
writePage(cacheKey, fresh);
setRows(fresh.rows);
setTotalRows(fresh.totalRows);
}
setError('');
} catch (e) {
if (!cancelled) setError((e as Error).message);
}
};
load().catch(() => undefined);
const unsubscribe = onRequestsChanged(() => {
load().catch(() => undefined);
});
return () => {
cancelled = true;
unsubscribe();
};
}, [context, types, debouncedQuery, page]);
const hasQuery = debouncedQuery.trim().length > 0;
const totalPages = Math.max(1, Math.ceil(totalRows / PAGE_SIZE));
return (
<section className={styles.view}>
<div className={styles.head}>
<h2 className={styles.heading}>
Поиск
{hasQuery && rows && <span className={styles.count}>{totalRows}</span>}
</h2>
<input
type="search"
className={styles.search}
placeholder="Введите текст — ищем по своим заявкам и по тем, где вы участвуете…"
value={query}
onChange={(e) => setQuery(e.target.value)}
autoFocus
/>
</div>
{error && <div className={styles.error}>Не удалось выполнить поиск: {error}</div>}
{!error && !hasQuery && <div className={styles.empty}>Введите текст, чтобы начать поиск.</div>}
{!error && hasQuery && rows === undefined && <div className={styles.empty}>Ищем…</div>}
{!error && hasQuery && rows && totalRows === 0 && (
<div className={styles.empty}>По запросу «{debouncedQuery}» ничего не найдено.</div>
)}
{!error && hasQuery && rows && totalRows > 0 && (
<>
<Table rows={rows} onOpen={onOpen} />
<Pager page={page} totalPages={totalPages} onChange={setPage} />
</>
)}
</section>
);
};