278 lines
12 KiB
TypeScript
278 lines
12 KiB
TypeScript
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 } from '../../shared/usePersistedQuery';
|
||
import { Pager } from './Pager';
|
||
import styles from './RequestsTableView.module.scss';
|
||
|
||
const PAGE_SIZE = 20;
|
||
|
||
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;
|
||
}
|
||
}
|
||
|
||
// Таблица заявок. Клик в ЛЮБОМ месте строки открывает заявку (кликабельна вся
|
||
// строка, а не только название), поэтому строка — <tr> с onClick + tabIndex
|
||
// (доступ с клавиатуры: Enter/Space).
|
||
//
|
||
// «approvals»: автор — не текущий пользователь, поэтому нужна колонка «Автор»;
|
||
// статус не показываем — здесь все заявки по определению на согласовании.
|
||
// «own»: автор всегда текущий пользователь, зато важен статус.
|
||
const Table: React.FC<{
|
||
rows: IMyRequest[];
|
||
kind: 'approvals' | 'own';
|
||
onOpen: (request: IMyRequest) => void;
|
||
}> = ({ rows, kind, 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>
|
||
{kind === 'approvals' && <th className={styles.colAuthor}>Автор</th>}
|
||
{kind === 'own' && <th className={styles.colStatus}>Статус</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>
|
||
{kind === 'approvals' && (
|
||
<td className={styles.colAuthor}>
|
||
{[r.authorName, r.authorEmail].filter(Boolean).join(' · ') || '—'}
|
||
</td>
|
||
)}
|
||
{kind === 'own' && (
|
||
<td className={styles.colStatus}>
|
||
<span className={`${styles.status} ${statusClass(r.status)}`}>{r.status || '—'}</span>
|
||
</td>
|
||
)}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
);
|
||
|
||
// Раздел с таблицей заявок: поиск по всем полям + вывод постранично.
|
||
//
|
||
// Движок выбирается по наличию текста в поиске, а не по разделу: БЕЗ запроса —
|
||
// всегда обычный 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,
|
||
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] = usePersistedQuery(kind);
|
||
const [error, setError] = React.useState<string>('');
|
||
|
||
React.useEffect(() => setPage(1), [query, 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 load = async (): Promise<void> => {
|
||
try {
|
||
if (kind === 'own') {
|
||
const hasOwnQuery = query.trim().length > 0;
|
||
const r = hasOwnQuery
|
||
? await searchOwnRequests(context, types, query, page)
|
||
: await getOwnRequests(context, types, page);
|
||
if (cancelled) return;
|
||
setRows(r.rows);
|
||
setTotalRows(r.totalRows);
|
||
setError('');
|
||
return;
|
||
}
|
||
|
||
const hasQuery = query.trim().length > 0;
|
||
|
||
if (!hasQuery) {
|
||
// Без поиска — страница уже нарезана и дочитана сервером.
|
||
const r = await getPendingApproval(context, 'myTurn', page);
|
||
if (cancelled) return;
|
||
console.table(r.debug);
|
||
setRows(r.rows);
|
||
setTotalRows(r.totalRows);
|
||
setError('');
|
||
if (onMyTurnCountChange) onMyTurnCountChange(r.totalRows);
|
||
return;
|
||
}
|
||
|
||
// С поиском — сначала полный список «где я есть в маршруте» (без
|
||
// автора), фильтруем текстом, и только потом режем на страницы и
|
||
// дочитываем автора — только для видимой страницы.
|
||
const idx = await getPendingApprovalIndex(context, 'involved');
|
||
if (cancelled) return;
|
||
console.table(idx.debug);
|
||
const filtered = await filterPendingByQuery(context, idx.types, idx.rows, query);
|
||
if (cancelled) return;
|
||
const pageRows = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
|
||
await hydratePendingAuthors(context, idx.types, pageRows);
|
||
if (cancelled) return;
|
||
setRows(pageRows);
|
||
setTotalRows(filtered.length);
|
||
setError('');
|
||
} 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]);
|
||
|
||
// Обе ветки теперь отдают уже нарезанную страницу — просто показываем как есть.
|
||
const q = query.trim();
|
||
const total = totalRows;
|
||
const visible = rows || [];
|
||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||
|
||
const heading = kind === 'approvals' ? 'Ждут вашего решения' : 'Мои заявки';
|
||
|
||
return (
|
||
<section className={styles.view}>
|
||
<div className={styles.head}>
|
||
<h2 className={styles.heading}>
|
||
{heading}
|
||
{rows && <span className={styles.count}>{total}</span>}
|
||
</h2>
|
||
<input
|
||
type="search"
|
||
className={styles.search}
|
||
placeholder="Поиск по заявкам…"
|
||
value={query}
|
||
onChange={(e) => setQuery(e.target.value)}
|
||
/>
|
||
</div>
|
||
|
||
{error && <div className={styles.error}>Не удалось загрузить заявки: {error}</div>}
|
||
{!error && rows === undefined && <div className={styles.empty}>Загрузка…</div>}
|
||
{!error && rows && total === 0 && (
|
||
<div className={styles.empty}>
|
||
{q
|
||
? `По запросу «${query}» ничего не найдено.`
|
||
: kind === 'approvals'
|
||
? 'Нет заявок, ждущих вашего решения.'
|
||
: 'Вы ещё не создавали заявок.'}
|
||
</div>
|
||
)}
|
||
{!error && rows && total > 0 && (
|
||
<>
|
||
<Table rows={visible} kind={kind} onOpen={onOpen} />
|
||
<Pager page={page} totalPages={totalPages} onChange={setPage} />
|
||
</>
|
||
)}
|
||
</section>
|
||
);
|
||
};
|