Переработка списков (#5)
Tests / frontend-test (push) Successful in 1m25s

Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/timur.gorodetskiy/request-portal/pulls/5
This commit was merged in pull request #5.
This commit is contained in:
2026-08-11 04:23:05 +00:00
parent dcf3f06121
commit 70450aa66d
34 changed files with 2131 additions and 382 deletions
@@ -1,18 +1,21 @@
import * as React from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import {
ApprovalQueueKind,
filterPendingByQuery,
getApprovalQueue,
getApprovalQueueIndex,
getLinkedTypes,
getOwnRequests,
getPendingApproval,
getPendingApprovalIndex,
getOwnRequestsByStatus,
hydrateDecisionComments,
hydratePendingAuthors,
searchOwnRequests,
} from './myRequests.service';
import { IMyRequest } from './models/IMyRequest';
import { REQUEST_STATUS, IRequestType } from '../../shared/approval';
import { REQUEST_STATUS, REQUEST_STATUS_CHOICES, IRequestType, StatusBadge } from '../../shared/approval';
import { onRequestsChanged } from '../../shared/requestEvents';
import { usePersistedQuery, useDebouncedQuery } from '../../shared/usePersistedQuery';
import { useRoutedQuery } from '../../shared/usePersistedQuery';
import { IPage, pageKey, pageSignature, readPage, writePage } from './pageCache';
import { Pager } from './Pager';
import styles from './RequestsTableView.module.scss';
@@ -26,16 +29,157 @@ 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': 'Вы ещё ничего не выполнили.',
};
// Цвет пункта в выпадающем списке фильтра — то же сопоставление статус→цвет,
// что в StatusBadge (VARIANT), только цвет текста, а не бейдж целиком: внутри
// нативного <option> нельзя отрендерить компонент, а background-color на нём
// браузеры показывают ненадёжно (в отличие от color). Если сопоставление там
// поменяется — поправить и здесь, второй раз найти дублирование негде.
function statusOptionClassName(status: string): string {
switch (status) {
case REQUEST_STATUS.onApproval:
case REQUEST_STATUS.onExecution:
return styles.optionOnApproval;
case REQUEST_STATUS.approved:
case REQUEST_STATUS.executed:
return styles.optionApproved;
case REQUEST_STATUS.rejected:
return styles.optionRejected;
case REQUEST_STATUS.returned:
return styles.optionReturned;
case REQUEST_STATUS.execution:
return styles.optionExecution;
case REQUEST_STATUS.closed:
return styles.optionClosed;
default:
return styles.optionDraft;
}
}
// Колонки: «Автор» нужен там, где заявка чужая; «Статус» — где он не следует из
// самого раздела. В истории решений важно и то, и другое: заявка чужая, а её
// текущий статус мог уйти дальше вашего решения.
//
// 'rework' — свои заявки (автор не нужен) И все с одним статусом «Возвращена на
// доработку», раз именно по нему раздел и отобран (см. getOwnRequestsByStatus):
// колонка из одинаковых плашек ничего не сообщает, только шумит. Та же логика,
// что у очередей согласования/исполнения.
//
// 'own:created' — статус нужен, только пока показаны ВСЕ заявки. Стоит выбрать
// его в фильтре, и колонка вырождается в тот же столбец одинаковых плашек,
// поэтому она зависит не от одного вида, а ещё и от фильтра.
//
// Статус решения, ОБЩИЙ для всех строк раздела, — если он есть, показывать его
// колонкой незачем (столбец одинаковых плашек), зато полезен комментарий
// согласующего: почему вернули или почему отклонили. Именно за этим в такие
// списки и заходят.
function decisionStatusFor(view: ListView, statusFilter: string): string | undefined {
if (view === 'rework') return REQUEST_STATUS.returned;
if (view === 'own:rejected') return REQUEST_STATUS.rejected;
if (
view === 'own:created' &&
(statusFilter === REQUEST_STATUS.returned || statusFilter === REQUEST_STATUS.rejected)
) {
return statusFilter;
}
return undefined;
}
interface ITableColumns {
author: boolean;
status: boolean;
/** Заголовок колонки с комментарием; undefined — колонки нет. */
commentLabel?: string;
}
function columnsFor(view: ListView, statusFilter: string): ITableColumns {
const decisionStatus = decisionStatusFor(view, statusFilter);
const commentLabel =
decisionStatus === REQUEST_STATUS.rejected
? 'Причина отклонения'
: decisionStatus === REQUEST_STATUS.returned
? 'Причина возврата'
: undefined;
if (view === 'approvals' || view === 'execution') return { author: true, status: false };
if (view === 'rework') return { author: false, status: false, commentLabel };
if (view === 'own:created') return { author: false, status: !statusFilter, commentLabel };
// История решений: заявка чужая, поэтому нужен автор. Статус — только если он
// не предопределён самой вкладкой («Отклонённые мной» — это всегда «Отклонено»,
// а вот у согласованных/выполненных заявка могла уйти дальше по маршруту).
return { author: true, status: !decisionStatus, commentLabel };
}
export interface IRequestsTableViewProps {
context: WebPartContext;
/** 'approvals' — ждут решения текущего пользователя; 'own' — созданные им. */
kind: 'approvals' | 'own';
view: ListView;
/** Фильтр по статусу заявки; только для view='own:created'. '' — все статусы. */
statusFilter?: string;
onStatusFilterChange?: (status: string) => void;
/**
* Текущая страница (1-based) и её смена. Страница живёт в маршруте, а не
* внутри компонента: так она попадает в адрес (ссылку можно передать),
* переживает открытие/закрытие заявки и запоминается для этой таблицы
* (см. persistPage в shared/routing.ts).
*/
page: number;
onPageChange: (page: number) => void;
/**
* Текст поиска и его смена. Тоже живёт в маршруте (см. page выше): попадает
* в адрес, переживает открытие заявки и запоминается для этой таблицы.
*/
query: string;
onQueryChange: (query: string) => void;
/** Клик по строке — открыть заявку (в выезжающей панели). */
onOpen: (request: IMyRequest) => void;
/** Точное число «ждут решения СЕЙЧАС» (kind='approvals', без поиска) — для
* счётчика в SideNav, чтобы не гонять для него отдельный параллельный
* запрос, который мог бы разойтись по времени с тем, что видно в таблице. */
onMyTurnCountChange?: (count: number) => void;
}
function formatDate(iso: string): string {
@@ -46,35 +190,19 @@ function formatDate(iso: string): string {
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»: автор всегда текущий пользователь, зато важен статус.
// Набор колонок зависит от раздела и от фильтра — см. columnsFor.
const Table: React.FC<{
rows: IMyRequest[];
kind: 'approvals' | 'own';
view: ListView;
statusFilter: string;
onOpen: (request: IMyRequest) => void;
}> = ({ rows, kind, onOpen }) => (
}> = ({ rows, view, statusFilter, onOpen }) => {
const columns = columnsFor(view, statusFilter);
return (
<div className={styles.tableWrap}>
<table className={styles.table}>
<thead>
@@ -83,8 +211,9 @@ 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>}
{columns.commentLabel && <th className={styles.colComment}>{columns.commentLabel}</th>}
</tr>
</thead>
<tbody>
@@ -107,14 +236,19 @@ 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>
{r.status ? <StatusBadge status={r.status} size="sm" /> : '—'}
</td>
)}
{columns.commentLabel && (
<td className={styles.colComment}>
{r.decisionComment || <span className={styles.commentEmpty}>Без комментария</span>}
</td>
)}
</tr>
@@ -122,7 +256,8 @@ const Table: React.FC<{
</tbody>
</table>
</div>
);
);
};
// Раздел с таблицей заявок: поиск по всем полям + вывод постранично.
//
@@ -133,40 +268,62 @@ 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:
// managed property для произвольной Choice-колонки ненадёжна (см.
// getOwnRequestsByStatus в myRequests.service.ts — там объяснение и ссылка на
// реальный инцидент). Вместо этого Search ищет только по автору (надёжная
// AuthorOWSUSER), а статус фильтруется в памяти по полям, дочитанным через
// REST; постраничность там же, в памяти.
//
// Все варианты перечитываются по событию rp-requests-changed
// (shared/requestEvents) — после создания заявки и после решения согласующего.
// Поиск идёт по индексу, поэтому свежая правка может проявиться не мгновенно.
export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
context,
kind,
view,
statusFilter = '',
onStatusFilterChange,
page,
onPageChange,
query,
onQueryChange,
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>('');
// query — то, что в поле ввода (меняется сразу); debouncedQuery — то, что
// уходит в запрос (после паузы в наборе). Всё ниже завязано на debouncedQuery,
// иначе поиск бил бы запросом по каждой набранной букве.
const debouncedQuery = useDebouncedQuery(query);
React.useEffect(() => setPage(1), [debouncedQuery, kind]);
// input — то, что в поле ввода (меняется сразу); в маршрут значение уходит
// после паузы в наборе. Данные грузятся по МАРШРУТНОМУ query — он и так уже
// «отложенный», отдельный debounce здесь был бы вторым по счёту.
const [input, setInput] = useRoutedQuery(query, onQueryChange);
const debouncedQuery = query;
// Какой ИМЕННО таблице принадлежат лежащие в состоянии rows/totalRows.
// Компонент один на все разделы (см. LIST_VIEWS в RequestPortal), поэтому при
// переключении раздела его состояние ещё хранит данные ПРЕДЫДУЩЕГО, пока не
// отработает загрузка. Без этой отметки сброс страницы ниже принимал решение
// по чужому totalRows и сбивал страницу на первую — из-за чего запомненная
// страница затиралась при каждом переходе по левому меню.
// Страница в ключ НЕ входит: totalRows один на всю таблицу, а не на страницу.
const dataKey = `${view}|${statusFilter}|${debouncedQuery.trim()}`;
const [loadedKey, setLoadedKey] = React.useState<string>('');
// Страницу при смене запроса здесь НЕ сбрасываем: и запрос, и страница живут
// в маршруте, и меняются они одним переходом (см. setQuery в RequestPortal).
React.useEffect(() => {
let cancelled = false;
@@ -185,7 +342,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]);
// Сразу показываем то, что уже загружали для ЭТОЙ вкладки (и этого запроса
// и страницы), а обновление идёт фоном. Компонент один на обе вкладки —
@@ -195,30 +352,56 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
const cached = readPage(cacheKey);
setRows(cached ? cached.rows : undefined);
setTotalRows(cached ? cached.totalRows : 0);
// Пока не подтвердим, что загруженное относится к ТЕКУЩЕЙ таблице, ничего
// на основании totalRows решать нельзя — см. loadedKey ниже.
setLoadedKey(cached ? dataKey : '');
const fetchPage = async (): Promise<IPage> => {
if (kind === 'own') {
return debouncedQuery.trim().length > 0
const q = debouncedQuery.trim();
const fetchRows = async (): Promise<IPage> => {
// «Доработка» — тот же список, что «Инициированные», просто со статусом,
// зашитым в раздел, а не выбранным в фильтре.
if (view === 'rework' || (view === 'own:created' && statusFilter)) {
const status = view === 'rework' ? REQUEST_STATUS.returned : statusFilter;
return getOwnRequestsByStatus(context, types, status, page, debouncedQuery);
}
if (view === 'own:created') {
return q.length > 0
? searchOwnRequests(context, types, debouncedQuery, page)
: getOwnRequests(context, types, page);
}
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);
return { rows: pageRows, totalRows: filtered.length };
};
// Комментарий к решению дочитываем ОДНИМ местом на все ветки: «Доработка» и
// «Отклонённые мной» приходят разными путями (по автору и по строкам
// маршрута соответственно), а колонка у них одинаковая по смыслу. И только
// для видимой страницы — как и автор в hydratePendingAuthors.
const fetchPage = async (): Promise<IPage> => {
const result = await fetchRows();
const decisionStatus = decisionStatusFor(view, statusFilter);
if (decisionStatus) {
await hydrateDecisionComments(context, types, result.rows, decisionStatus);
}
return result;
};
const load = async (): Promise<void> => {
try {
const fresh = await fetchPage();
@@ -233,10 +416,10 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
setRows(fresh.rows);
setTotalRows(fresh.totalRows);
}
// Данные точно от текущей таблицы — теперь по ним можно судить о
// количестве страниц (см. сброс страницы ниже).
setLoadedKey(dataKey);
setError('');
if (onMyTurnCountChange && kind === 'approvals' && debouncedQuery.trim().length === 0) {
onMyTurnCountChange(fresh.totalRows);
}
} catch (e) {
if (!cancelled) setError((e as Error).message);
}
@@ -256,29 +439,58 @@ 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' ? 'Ждут вашего решения' : 'Мои заявки';
// Страница может оказаться за пределами набора: пришла из старой ссылки или
// из запомненной, а заявок с тех пор стало меньше (часть согласовали, и они
// ушли из этой очереди). Показывать пустую таблицу с непустым счётчиком —
// выглядит как поломка, поэтому возвращаемся на последнюю существующую.
//
// Строго ПОСЛЕ проверки loadedKey: totalRows в состоянии может быть ещё от
// предыдущей таблицы (компонент общий), и решение по нему сбило бы страницу
// на первую при обычном переходе по левому меню.
React.useEffect(() => {
if (loadedKey !== dataKey) return;
if (rows && page > totalPages) onPageChange(totalPages);
}, [loadedKey, dataKey, rows, page, totalPages, onPageChange]);
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
// Цвет самого <select> (закрытое состояние) — тем же классом, что и
// у выбранного <option>: statusFilter задаёт нейтральные border/фон,
// а конкретный statusOptionClassName(statusFilter) перекрывает color.
className={`${styles.statusFilter} ${statusFilter ? statusOptionClassName(statusFilter) : ''}`}
value={statusFilter}
onChange={(e) => onStatusFilterChange(e.target.value)}
aria-label="Фильтр по статусу"
>
<option value="">Все статусы</option>
{REQUEST_STATUS_CHOICES.map((s) => (
<option key={s} value={s} className={statusOptionClassName(s)}>
{s}
</option>
))}
</select>
)}
<input
type="search"
className={styles.search}
placeholder="Поиск по заявкам…"
value={query}
onChange={(e) => setQuery(e.target.value)}
value={input}
onChange={(e) => setInput(e.target.value)}
/>
</div>
@@ -286,17 +498,13 @@ 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} />
<Pager page={page} totalPages={totalPages} onChange={setPage} />
<Table rows={visible} view={view} statusFilter={statusFilter} onOpen={onOpen} />
<Pager page={page} totalPages={totalPages} onChange={onPageChange} />
</>
)}
</section>