скелет готов
This commit is contained in:
@@ -1,16 +1,17 @@
|
||||
import * as React from 'react';
|
||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import {
|
||||
ApprovalQueueKind,
|
||||
filterPendingByQuery,
|
||||
getApprovalQueue,
|
||||
getApprovalQueueIndex,
|
||||
getLinkedTypes,
|
||||
getOwnRequests,
|
||||
getPendingApproval,
|
||||
getPendingApprovalIndex,
|
||||
hydratePendingAuthors,
|
||||
searchOwnRequests,
|
||||
} from './myRequests.service';
|
||||
import { IMyRequest } from './models/IMyRequest';
|
||||
import { REQUEST_STATUS, IRequestType } from '../../shared/approval';
|
||||
import { REQUEST_STATUS, REQUEST_STATUS_CHOICES, IRequestType } from '../../shared/approval';
|
||||
import { onRequestsChanged } from '../../shared/requestEvents';
|
||||
import { usePersistedQuery, useDebouncedQuery } from '../../shared/usePersistedQuery';
|
||||
import { IPage, pageKey, pageSignature, readPage, writePage } from './pageCache';
|
||||
@@ -26,16 +27,72 @@ const PAGE_SIZE = 20;
|
||||
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';
|
||||
|
||||
// Представления, которые строятся на строках маршрута пользователя, и их очередь
|
||||
// в сервисе. Всё, чего здесь нет ('rework'/'own:created'), читается из списка
|
||||
// данных по автору.
|
||||
const QUEUE_BY_VIEW: { [key: string]: ApprovalQueueKind } = {
|
||||
approvals: 'approvals',
|
||||
execution: 'execution',
|
||||
'own:approved': 'approved',
|
||||
'own:rejected': 'rejected',
|
||||
'own:executed': 'executed',
|
||||
};
|
||||
|
||||
const HEADINGS: Record<ListView, string> = {
|
||||
approvals: 'Ждут вашего согласования',
|
||||
execution: 'Ждут вашего исполнения',
|
||||
rework: 'Возвращены вам на доработку',
|
||||
'own:created': 'Инициированные мной',
|
||||
'own:approved': 'Согласованные мной',
|
||||
'own:rejected': 'Отклонённые мной',
|
||||
'own:executed': 'Выполненные мной',
|
||||
};
|
||||
|
||||
const EMPTY_TEXTS: Record<ListView, string> = {
|
||||
approvals: 'Нет заявок, ждущих вашего согласования.',
|
||||
execution: 'Нет заявок, ждущих вашего исполнения.',
|
||||
rework: 'Нет заявок, возвращённых вам на доработку.',
|
||||
'own:created': 'Вы ещё не создавали заявок.',
|
||||
'own:approved': 'Вы ещё ничего не согласовали.',
|
||||
'own:rejected': 'Вы ещё ничего не отклоняли.',
|
||||
'own:executed': 'Вы ещё ничего не выполнили.',
|
||||
};
|
||||
|
||||
// Колонки: «Автор» нужен там, где заявка чужая; «Статус» — где он не следует из
|
||||
// самого раздела. В истории решений важно и то, и другое: заявка чужая, а её
|
||||
// текущий статус мог уйти дальше вашего решения.
|
||||
function columnsFor(view: ListView): { author: boolean; status: boolean } {
|
||||
if (view === 'approvals' || view === 'execution') return { author: true, status: false };
|
||||
if (view === 'rework' || view === 'own:created') return { author: false, status: true };
|
||||
return { author: true, status: true };
|
||||
}
|
||||
|
||||
export interface IRequestsTableViewProps {
|
||||
context: WebPartContext;
|
||||
/** 'approvals' — ждут решения текущего пользователя; 'own' — созданные им. */
|
||||
kind: 'approvals' | 'own';
|
||||
view: ListView;
|
||||
/** Фильтр по статусу заявки; только для view='own:created'. '' — все статусы. */
|
||||
statusFilter?: string;
|
||||
onStatusFilterChange?: (status: string) => void;
|
||||
/** Клик по строке — открыть заявку (в выезжающей панели). */
|
||||
onOpen: (request: IMyRequest) => void;
|
||||
/** Точное число «ждут решения СЕЙЧАС» (kind='approvals', без поиска) — для
|
||||
* счётчика в SideNav, чтобы не гонять для него отдельный параллельный
|
||||
* запрос, который мог бы разойтись по времени с тем, что видно в таблице. */
|
||||
onMyTurnCountChange?: (count: number) => void;
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
@@ -72,9 +129,11 @@ function statusClass(status: string): string {
|
||||
// «own»: автор всегда текущий пользователь, зато важен статус.
|
||||
const Table: React.FC<{
|
||||
rows: IMyRequest[];
|
||||
kind: 'approvals' | 'own';
|
||||
view: ListView;
|
||||
onOpen: (request: IMyRequest) => void;
|
||||
}> = ({ rows, kind, onOpen }) => (
|
||||
}> = ({ rows, view, onOpen }) => {
|
||||
const columns = columnsFor(view);
|
||||
return (
|
||||
<div className={styles.tableWrap}>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
@@ -83,8 +142,8 @@ const Table: React.FC<{
|
||||
<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>}
|
||||
{columns.author && <th className={styles.colAuthor}>Автор</th>}
|
||||
{columns.status && <th className={styles.colStatus}>Статус</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -107,12 +166,12 @@ const Table: React.FC<{
|
||||
<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' && (
|
||||
{columns.author && (
|
||||
<td className={styles.colAuthor}>
|
||||
{[r.authorName, r.authorEmail].filter(Boolean).join(' · ') || '—'}
|
||||
</td>
|
||||
)}
|
||||
{kind === 'own' && (
|
||||
{columns.status && (
|
||||
<td className={styles.colStatus}>
|
||||
<span className={`${styles.status} ${statusClass(r.status)}`}>{r.status || '—'}</span>
|
||||
</td>
|
||||
@@ -122,7 +181,8 @@ const Table: React.FC<{
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
// Раздел с таблицей заявок: поиск по всем полям + вывод постранично.
|
||||
//
|
||||
@@ -133,40 +193,44 @@ const Table: React.FC<{
|
||||
// результат мог быть неустойчивым (заявка то находилась, то нет, в зависимости
|
||||
// от того, попала ли она в окно подстраховки); эту гибридную логику убрали.
|
||||
//
|
||||
// 'own' без поиска — getOwnRequests (REST по AuthorId, страница режется в
|
||||
// памяти). 'own' с поиском — searchOwnRequests (Search, RowLimit/StartRow).
|
||||
// 'own:created' без фильтра и поиска — getOwnRequests (Search + REST-довесок на
|
||||
// свежесть). С поиском — searchOwnRequests (Search, RowLimit/StartRow).
|
||||
//
|
||||
// 'approvals' без поиска — getPendingApproval, REST по ApproverId(+Status),
|
||||
// страница нарезается на СЕРВЕРЕ (сам берёт нужный срез индекса и дочитывает
|
||||
// автора только для него).
|
||||
// 'approvals' с поиском — область расширяется до «где пользователь вообще есть
|
||||
// в маршруте» (getPendingApprovalIndex, ПОЛНЫЙ список без автора — постранично
|
||||
// его тут не взять, т.к. фильтрация текстом идёт уже после), затем текстовый
|
||||
// фильтр через Search (filterPendingByQuery), и только после фильтрации —
|
||||
// нарезка на страницы и дочитывание автора (hydratePendingAuthors) — тоже
|
||||
// только для видимой страницы, а не для всего отфильтрованного набора.
|
||||
// Очереди и история решений ('approvals'/'execution'/'own:*' кроме created) без
|
||||
// поиска — getApprovalQueue: REST по ApproverId(+Status), страница нарезается на
|
||||
// СЕРВЕРЕ (сам берёт нужный срез индекса и дочитывает автора только для него).
|
||||
// С поиском — getApprovalQueueIndex даёт ПОЛНЫЙ список этой же очереди без
|
||||
// автора (постранично его тут не взять, т.к. фильтрация текстом идёт уже
|
||||
// после), затем текстовый фильтр через Search (filterPendingByQuery), и только
|
||||
// после фильтрации — нарезка на страницы и дочитывание автора
|
||||
// (hydratePendingAuthors) — тоже только для видимой страницы.
|
||||
//
|
||||
// Оба варианта перечитываются по событию rp-requests-changed
|
||||
// Отбор по статусу ЗАЯВКИ ('rework' и фильтр на 'own:created') уходит в тот же
|
||||
// KQL отдельным условием (см. MP_TICKET_STATUS) — то есть работает вместе с
|
||||
// текстовым поиском и с серверной постраничностью, без вычитывания архива.
|
||||
//
|
||||
// Все варианты перечитываются по событию rp-requests-changed
|
||||
// (shared/requestEvents) — после создания заявки и после решения согласующего.
|
||||
// Поиск идёт по индексу, поэтому свежая правка может проявиться не мгновенно.
|
||||
export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
||||
context,
|
||||
kind,
|
||||
view,
|
||||
statusFilter = '',
|
||||
onStatusFilterChange,
|
||||
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 [query, setQuery] = usePersistedQuery(view);
|
||||
const [error, setError] = React.useState<string>('');
|
||||
// query — то, что в поле ввода (меняется сразу); debouncedQuery — то, что
|
||||
// уходит в запрос (после паузы в наборе). Всё ниже завязано на debouncedQuery,
|
||||
// иначе поиск бил бы запросом по каждой набранной букве.
|
||||
const debouncedQuery = useDebouncedQuery(query);
|
||||
|
||||
React.useEffect(() => setPage(1), [debouncedQuery, kind]);
|
||||
React.useEffect(() => setPage(1), [debouncedQuery, view, statusFilter]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -185,7 +249,7 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
||||
React.useEffect(() => {
|
||||
if (types === undefined) return;
|
||||
let cancelled = false;
|
||||
const cacheKey = pageKey('table', [kind, debouncedQuery.trim(), page]);
|
||||
const cacheKey = pageKey('table', [view, statusFilter, debouncedQuery.trim(), page]);
|
||||
|
||||
// Сразу показываем то, что уже загружали для ЭТОЙ вкладки (и этого запроса
|
||||
// и страницы), а обновление идёт фоном. Компонент один на обе вкладки —
|
||||
@@ -196,23 +260,29 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
||||
setRows(cached ? cached.rows : undefined);
|
||||
setTotalRows(cached ? cached.totalRows : 0);
|
||||
|
||||
const q = debouncedQuery.trim();
|
||||
|
||||
const fetchPage = async (): Promise<IPage> => {
|
||||
if (kind === 'own') {
|
||||
return debouncedQuery.trim().length > 0
|
||||
? searchOwnRequests(context, types, debouncedQuery, page)
|
||||
: getOwnRequests(context, types, page);
|
||||
// Свои заявки: «Доработка» — тот же список, что «Инициированные», просто
|
||||
// со статусом, зашитым в раздел, а не выбранным в фильтре.
|
||||
if (view === 'rework' || view === 'own:created') {
|
||||
const status = view === 'rework' ? REQUEST_STATUS.returned : statusFilter;
|
||||
return q.length > 0
|
||||
? searchOwnRequests(context, types, debouncedQuery, page, status)
|
||||
: getOwnRequests(context, types, page, status);
|
||||
}
|
||||
|
||||
if (debouncedQuery.trim().length === 0) {
|
||||
const queue = QUEUE_BY_VIEW[view];
|
||||
if (q.length === 0) {
|
||||
// Без поиска — страница уже нарезана и дочитана сервером.
|
||||
const r = await getPendingApproval(context, 'myTurn', page);
|
||||
const r = await getApprovalQueue(context, queue, page);
|
||||
return { rows: r.rows, totalRows: r.totalRows };
|
||||
}
|
||||
|
||||
// С поиском — сначала полный список «где я есть в маршруте» (без
|
||||
// автора), фильтруем текстом, и только потом режем на страницы и
|
||||
// дочитываем автора — только для видимой страницы.
|
||||
const idx = await getPendingApprovalIndex(context, 'involved');
|
||||
// С поиском — сначала полный список этой очереди (без автора), фильтруем
|
||||
// текстом, и только потом режем на страницы и дочитываем автора — только
|
||||
// для видимой страницы.
|
||||
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);
|
||||
@@ -234,9 +304,6 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
||||
setTotalRows(fresh.totalRows);
|
||||
}
|
||||
setError('');
|
||||
if (onMyTurnCountChange && kind === 'approvals' && debouncedQuery.trim().length === 0) {
|
||||
onMyTurnCountChange(fresh.totalRows);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!cancelled) setError((e as Error).message);
|
||||
}
|
||||
@@ -256,23 +323,36 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
||||
unsubscribe();
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [context, types, kind, debouncedQuery, page]);
|
||||
}, [context, types, view, statusFilter, 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 (
|
||||
<section className={styles.view}>
|
||||
<div className={styles.head}>
|
||||
<h2 className={styles.heading}>
|
||||
{heading}
|
||||
{HEADINGS[view]}
|
||||
{rows && <span className={styles.count}>{total}</span>}
|
||||
</h2>
|
||||
{view === 'own:created' && onStatusFilterChange && (
|
||||
<select
|
||||
className={styles.statusFilter}
|
||||
value={statusFilter}
|
||||
onChange={(e) => onStatusFilterChange(e.target.value)}
|
||||
aria-label="Фильтр по статусу"
|
||||
>
|
||||
<option value="">Все статусы</option>
|
||||
{REQUEST_STATUS_CHOICES.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<input
|
||||
type="search"
|
||||
className={styles.search}
|
||||
@@ -286,16 +366,12 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
||||
{!error && rows === undefined && <div className={styles.empty}>Загрузка…</div>}
|
||||
{!error && rows && total === 0 && (
|
||||
<div className={styles.empty}>
|
||||
{q
|
||||
? `По запросу «${debouncedQuery}» ничего не найдено.`
|
||||
: kind === 'approvals'
|
||||
? 'Нет заявок, ждущих вашего решения.'
|
||||
: 'Вы ещё не создавали заявок.'}
|
||||
{q ? `По запросу «${debouncedQuery}» ничего не найдено.` : EMPTY_TEXTS[view]}
|
||||
</div>
|
||||
)}
|
||||
{!error && rows && total > 0 && (
|
||||
<>
|
||||
<Table rows={visible} kind={kind} onOpen={onOpen} />
|
||||
<Table rows={visible} view={view} onOpen={onOpen} />
|
||||
<Pager page={page} totalPages={totalPages} onChange={setPage} />
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user