Feature/request type access all requests (#22)
Tests / frontend-test (push) Successful in 1m24s

Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/timur.gorodetskiy/request-portal/pulls/22
This commit was merged in pull request #22.
This commit is contained in:
2026-09-25 11:48:09 +00:00
parent ebc74dc46d
commit 98be3980cb
15 changed files with 590 additions and 56 deletions
@@ -0,0 +1,14 @@
@import '../../../../styles/design-tokens';
.typePicker {
max-width: 360px;
margin-bottom: $sp-4;
}
.empty {
padding: $sp-6;
background: $white;
border: 1px solid $gray-200;
border-radius: $r-md;
color: $gray-500;
}
@@ -0,0 +1,103 @@
import * as React from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import { Alert, Select } from '@tnsu/ui-kit';
import { accessibleAllRequestsTypes, IRequestType } from '../../shared/approval';
import { RequestsTableView } from './RequestsTableView';
import { getLinkedTypes } from './myRequests.service';
import { IMyRequest } from './models/IMyRequest';
import styles from './AllRequestsFeature.module.scss';
export interface IAllRequestsFeatureProps {
context: WebPartContext;
/** Тип из маршрута (rpalltype) — сохраняется в адресе, чтобы ссылку можно было передать. */
selectedTypeId?: number;
onTypeChange: (typeId: number) => void;
page: number;
onPageChange: (page: number) => void;
query: string;
onQueryChange: (query: string) => void;
onOpen: (request: IMyRequest) => void;
}
/**
* Раздел «Все заявки»: доступен только тем, у кого в админке approval-route
* настроен RequestTypeAccess хотя бы на один тип заявки (см.
* shared/approval/requestTypeAccess.ts). Показывает заявки ВСЕХ авторов
* выбранного типа — в отличие от «Моих заявок», где отбор всегда идёт по
* текущему пользователю.
*/
export const AllRequestsFeature: React.FC<IAllRequestsFeatureProps> = ({
context,
selectedTypeId,
onTypeChange,
page,
onPageChange,
query,
onQueryChange,
onOpen,
}) => {
const [types, setTypes] = React.useState<IRequestType[] | undefined>(undefined);
const [error, setError] = React.useState<string>('');
React.useEffect(() => {
let cancelled = false;
getLinkedTypes(context)
.then((all) => accessibleAllRequestsTypes(context, all))
.then((accessible) => {
if (!cancelled) setTypes(accessible);
})
.catch((e) => {
if (!cancelled) setError((e as Error).message);
});
return () => {
cancelled = true;
};
}, [context]);
// Тип по умолчанию — из адреса, если он всё ещё доступен, иначе первый
// доступный. Отдельным эффектом (а не при рендере): смена выбора идёт через
// navigate() у вызывающей стороны, и до его отработки selectedTypeId ещё
// хранит предыдущее значение.
React.useEffect(() => {
if (!types || types.length === 0) return;
const stillValid = !!selectedTypeId && types.some((t) => t.id === selectedTypeId);
if (!stillValid) onTypeChange(types[0].id);
// eslint-disable-next-line react-hooks/exhaustive-deps -- onTypeChange стабилен по смыслу (см. gotoAllRequestsType в RequestPortal), включение его в deps дало бы лишний повторный запуск на каждый рендер
}, [types, selectedTypeId]);
if (error) {
return <Alert variant="err">Не удалось загрузить типы заявок: {error}</Alert>;
}
if (types === undefined) {
return <div className={styles.empty}>Загрузка…</div>;
}
if (types.length === 0) {
return <div className={styles.empty}>Вам не выдан доступ ни к одному типу заявок.</div>;
}
const selectedType = types.find((t) => t.id === selectedTypeId) ?? types[0];
return (
<div>
{types.length > 1 && (
<div className={styles.typePicker}>
<Select
value={String(selectedType.id)}
onChange={(v) => onTypeChange(Number(v))}
options={types.map((t) => ({ value: String(t.id), label: t.name }))}
/>
</div>
)}
<RequestsTableView
context={context}
view="all"
allRequestsType={selectedType}
page={page}
onPageChange={onPageChange}
query={query}
onQueryChange={onQueryChange}
onOpen={onOpen}
/>
</div>
);
};
@@ -3,6 +3,7 @@ import type { WebPartContext } from '@microsoft/sp-webpart-base';
import {
ApprovalQueueKind,
filterPendingByQuery,
getAllRequestsForType,
getApprovalQueue,
getApprovalQueueIndex,
getLinkedTypes,
@@ -50,7 +51,8 @@ export type ListView =
| 'own:created'
| 'own:approved'
| 'own:rejected'
| 'own:executed';
| 'own:executed'
| 'all';
// Представления, которые строятся на строках маршрута пользователя, и их очередь
// в сервисе. Всё, чего здесь нет ('rework'/'own:created'), читается из списка
@@ -63,6 +65,9 @@ const QUEUE_BY_VIEW: { [key: string]: ApprovalQueueKind } = {
'own:executed': 'executed',
};
// 'all' — заголовок и текст пустоты зависят от выбранного типа заявки
// (см. allRequestsType и heading/emptyText ниже), здесь только запасной
// вариант на случай, пока тип ещё не выбран/не загрузился.
const HEADINGS: Record<ListView, string> = {
approvals: 'Ждут вашего согласования',
execution: 'Ждут вашего исполнения',
@@ -71,6 +76,7 @@ const HEADINGS: Record<ListView, string> = {
'own:approved': 'Согласованные мной',
'own:rejected': 'Отклонённые мной',
'own:executed': 'Выполненные мной',
all: 'Все заявки',
};
const EMPTY_TEXTS: Record<ListView, string> = {
@@ -81,6 +87,7 @@ const EMPTY_TEXTS: Record<ListView, string> = {
'own:approved': 'Вы ещё ничего не согласовали.',
'own:rejected': 'Вы ещё ничего не отклоняли.',
'own:executed': 'Вы ещё ничего не выполнили.',
all: 'Заявок этого типа пока нет.',
};
// Цвет пункта в выпадающем списке фильтра — то же сопоставление статус→цвет,
@@ -157,6 +164,8 @@ function columnsFor(view: ListView, statusFilter: string): ITableColumns {
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 };
// 'all' — заявки всех авторов вперемешку, поэтому нужны и автор, и статус.
if (view === 'all') return { author: true, status: true };
// История решений: заявка чужая, поэтому нужен автор. Статус — только если он
// не предопределён самой вкладкой («Отклонённые мной» — это всегда «Отклонено»,
// а вот у согласованных/выполненных заявка могла уйти дальше по маршруту).
@@ -185,6 +194,8 @@ export interface IRequestsTableViewProps {
onQueryChange: (query: string) => void;
/** Клик по строке — открыть заявку (в выезжающей панели). */
onOpen: (request: IMyRequest) => void;
/** Только при view='all' — тип заявки, чьи заявки показываются целиком (без фильтра по автору). */
allRequestsType?: IRequestType;
}
function formatDate(iso: string): string {
@@ -330,6 +341,7 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
query,
onQueryChange,
onOpen,
allRequestsType,
}) => {
const [types, setTypes] = React.useState<IRequestType[] | undefined>(undefined);
const [rows, setRows] = React.useState<IMyRequest[] | undefined>(undefined);
@@ -361,7 +373,7 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
// по чужому totalRows и сбивал страницу на первую — из-за чего запомненная
// страница затиралась при каждом переходе по левому меню.
// Страница в ключ НЕ входит: totalRows один на всю таблицу, а не на страницу.
const dataKey = `${view}|${statusFilter}|${debouncedQuery.trim()}`;
const dataKey = `${view}|${statusFilter}|${debouncedQuery.trim()}|${allRequestsType?.id ?? ''}`;
const [loadedKey, setLoadedKey] = React.useState<string>('');
// Страницу при смене запроса здесь НЕ сбрасываем: и запрос, и страница живут
@@ -384,7 +396,7 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
React.useEffect(() => {
if (types === undefined) return;
let cancelled = false;
const cacheKey = pageKey('table', [view, statusFilter, debouncedQuery.trim(), page]);
const cacheKey = pageKey('table', [view, statusFilter, debouncedQuery.trim(), page, allRequestsType?.id ?? 0]);
// Сразу показываем то, что уже загружали для ЭТОЙ вкладки (и этого запроса
// и страницы), а обновление идёт фоном. Компонент один на обе вкладки —
@@ -422,6 +434,11 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
: getOwnRequests(context, types, page, onPartial);
}
if (view === 'all') {
if (!allRequestsType) return { rows: [], totalRows: 0 };
return getAllRequestsForType(context, allRequestsType, page, debouncedQuery);
}
const queue = QUEUE_BY_VIEW[view];
if (q.length === 0) {
// Без поиска — страница уже нарезана и дочитана сервером.
@@ -524,7 +541,7 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
unsubscribe();
window.clearInterval(timer);
};
}, [context, types, view, statusFilter, debouncedQuery, page, dataKey]);
}, [context, types, view, statusFilter, debouncedQuery, page, dataKey, allRequestsType]);
// Все ветки отдают уже нарезанную страницу — просто показываем как есть.
const q = debouncedQuery.trim();
@@ -561,7 +578,7 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
<section className={styles.view}>
<div className={styles.head}>
<h2 className={styles.heading}>
{HEADINGS[view]}
{view === 'all' && allRequestsType ? `Все заявки: ${allRequestsType.name}` : HEADINGS[view]}
{rows && <span className={styles.count}>{total}</span>}
</h2>
{view === 'own:created' && onStatusFilterChange && (
@@ -39,8 +39,14 @@ export interface IMyRequest {
siteUrl: string;
/** Ключ формы (rptype в URL режима согласования) — поле pageUrl из approval-route API. */
formKey: string;
/** 'own' — заявка, созданная текущим пользователем; 'approval' — ждёт его решения. */
kind: 'own' | 'approval';
/**
* 'own' — заявка, созданная текущим пользователем; 'approval' — ждёт его
* решения; 'other' — раздел «Все заявки» (доступ настроен в админке
* approval-route): заявка чужая и открывается только на просмотр, как и
* 'approval' (см. openRequest в RequestPortal.tsx — по kind !== 'own' решение
* то же самое).
*/
kind: 'own' | 'approval' | 'other';
/**
* Статус СВОЕЙ строки маршрута (не заявки) — «На согласовании», «Согласовано»
* и т.п. Заполняется только у kind='approval'. Нужен, чтобы разнести заявки
@@ -1140,7 +1140,7 @@ interface ISearchEntry {
* компромиссом — searchOwnRequests и раньше не подмешивал REST-подстраховку
* (в отличие от getOwnRequests, где её явно добавляет getFreshOwnRowsForType).
*/
function toMyRequestFromSearch(entry: ISearchEntry): IMyRequest {
function toMyRequestFromSearch(entry: ISearchEntry, kind: 'own' | 'other' = 'own'): IMyRequest {
const r = entry.raw;
const author = parseAuthorOwsUser(r[MP_AUTHOR]);
return {
@@ -1157,7 +1157,7 @@ function toMyRequestFromSearch(entry: ISearchEntry): IMyRequest {
requestTypeName: entry.type.name,
section: entry.type.section,
formKey: entry.type.pageUrl,
kind: 'own',
kind,
listId: entry.target.listId,
siteUrl: entry.target.siteUrl,
};
@@ -1370,11 +1370,48 @@ export async function searchOwnRequests(
const totalRows = results.TotalRows || 0;
// Поля — прямо из ответа индекса (см. toMyRequestFromSearch), REST-дочитывания
// здесь больше нет. Порядок задаёт релевантность поиска.
const rows = hitsToEntries(results, types).map(toMyRequestFromSearch);
const rows = hitsToEntries(results, types).map((e) => toMyRequestFromSearch(e));
return { rows, totalRows };
}
// «Все заявки» ОДНОГО типа — для пользователей, которым в админке
// approval-route выдан доступ видеть заявки этого типа целиком, а не только
// свои (см. shared/approval/requestTypeAccess.ts). В отличие от
// searchOwnRequests — без фильтра по автору, и только ОДИН тип (раздел уже
// показывает конкретный выбранный тип, а не все связанные разом). Только
// Search, без REST-подстраховки на свежесть — как и у searchOwnRequests.
//
// Весь найденный срез целиком (fetchAllSearchRows), а не постранично через
// Search RowLimit/StartRow: totalRows там был бы сырым счётчиком индекса ДО
// дедупа по (тип, Id) (см. hitsToEntries — TrimDuplicates:false намеренно, и
// индекс может вернуть один и тот же элемент несколькими хитами), и на
// странице оказывалось МЕНЬШЕ строк, чем показывал счётчик над таблицей.
// Тип здесь только один — набор в разумных пределах, дедуп и сортировка идут
// по всему сразу, страница нарезается уже в памяти.
export async function getAllRequestsForType(
context: WebPartContext,
type: IRequestType,
page: number,
query: string
): Promise<IOwnRequestsPage> {
const sp = spForSite(type.siteUrl as string, context);
const clauses = [pathClauseForTypes([type])];
const text = freeTextClause(query);
if (text) clauses.push(text);
const kql = clauses.join(' AND ');
const hits = await traced('Search: все заявки типа', () =>
fetchAllSearchRows(sp, kql, OWN_SELECT_PROPERTIES, 'Все заявки типа', [{ Property: 'Created', Direction: 1 }])
);
// kind: 'other' — заявка не обязательно текущего пользователя, открывается
// только на просмотр (см. IMyRequest.kind, openRequest в RequestPortal.tsx).
const rows = hitsToEntries({ PrimarySearchResults: hits }, [type]).map((e) => toMyRequestFromSearch(e, 'other'));
return { rows: rows.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE), totalRows: rows.length };
}
// Свои заявки с конкретным статусом ЗАЯВКИ (не строки маршрута) — «Доработка»
// и фильтр по статусу на вкладке «Инициированные».
//
@@ -1446,7 +1483,7 @@ async function ownRequestsByStatus(
SortList: [{ Property: 'Created', Direction: 1 }],
})
).then((results) => ({
rows: hitsToEntries(results, types).map(toMyRequestFromSearch),
rows: hitsToEntries(results, types).map((e) => toMyRequestFromSearch(e)),
totalRows: results.TotalRows || 0,
}));
@@ -1672,7 +1709,7 @@ export async function searchOwnAll(
traceSearchHits('Свои заявки', kql, types, results, listAbsUrl);
const entries = hitsToEntries(results, types);
const rows = entries.map(toMyRequestFromSearch);
const rows = entries.map((e) => toMyRequestFromSearch(e));
traceResultRows('Свои заявки', types, entries, rows);
return rows;