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
@@ -147,6 +147,28 @@ export async function getRequestTypeIdByPageUrl(context: WebPartContext, pageUrl
return found.id;
}
// GET /api/request-type-access/{requestTypeId}/emails — email'ы, кому в
// админке approval-route выдан доступ видеть ВСЕ заявки этого типа (не только
// свои). Тот же эндпоинт дёргает и Power Automate — здесь просто ещё один
// потребитель.
export async function getRequestTypeAccessEmails(
context: WebPartContext,
requestTypeId: number
): Promise<string[]> {
return withNetworkRetry(async () => {
const c = await client(context);
const response: HttpClientResponse = await c.get(
`${getApprovalApiBaseUrl()}/api/request-type-access/${requestTypeId}/emails`,
AadHttpClient.configurations.v1
);
if (!response.ok) {
throw new Error(`request-type-access/${requestTypeId}/emails: ${response.status} ${response.statusText}`);
}
const body: IApiResponse<string[]> = await response.json();
return body.data ?? [];
});
}
// POST /api/template-routes/resolve — планируемый маршрут для типа заявки и
// пользователя. Бэкенд сам подставляет исполнителей шага (executorEmail/position)
// по ролям исходя из userEmail. Параметр status выбирает ФАЗУ маршрута:
@@ -31,8 +31,11 @@ export {
getRequestTypeIdByPageUrl,
getListPartitions,
getCurrentListPartition,
getRequestTypeAccessEmails,
resolveRoute
} from './approvalRoute.service';
export { hasRequestTypeAccess, accessibleAllRequestsTypes } from './requestTypeAccess';
export { useAccessibleAllRequestsFormKeys } from './useAccessibleAllRequestsFormKeys';
export {
initListPartitions,
listPartitionsLoaded,
@@ -0,0 +1,48 @@
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { getRequestTypeAccessEmails } from './approvalRoute.service';
import { IRequestType } from './models/IApprovalRouteApi';
import { effectiveUserEmail } from '../impersonation';
// Кеш на уровне МОДУЛЯ, по requestTypeId — тот же приём, что и у
// requestTypesPromise в approvalRoute.service.ts: реестр доступа меняется
// только из админки approval-route, а не в течение сеанса портала. Ошибка НЕ
// кешируется, чтобы временный сбой сети не «залип» на весь сеанс.
const cache = new Map<number, Promise<string[]>>();
function emailsForType(context: WebPartContext, requestTypeId: number): Promise<string[]> {
let pending = cache.get(requestTypeId);
if (!pending) {
pending = getRequestTypeAccessEmails(context, requestTypeId).catch((e) => {
cache.delete(requestTypeId);
throw e;
});
cache.set(requestTypeId, pending);
}
return pending;
}
/**
* Есть ли у ТЕКУЩЕГО пользователя (effectiveUserEmail — учитывает отладочную
* подмену личности) доступ видеть все заявки этого типа. Ошибка запроса — не
* доступ, а не исключение наверх: как и hasReportsAccess, тихий сбой сети не
* должен ронять раздел, который его вызывает.
*/
export async function hasRequestTypeAccess(context: WebPartContext, requestTypeId: number): Promise<boolean> {
const email = effectiveUserEmail(context).trim().toLowerCase();
if (!email) return false;
try {
const emails = await emailsForType(context, requestTypeId);
return emails.some((e) => e.trim().toLowerCase() === email);
} catch {
return false;
}
}
/** Из переданных типов — те, чьи заявки текущему пользователю разрешено видеть целиком. */
export async function accessibleAllRequestsTypes(
context: WebPartContext,
types: IRequestType[]
): Promise<IRequestType[]> {
const flags = await Promise.all(types.map((t) => hasRequestTypeAccess(context, t.id)));
return types.filter((_, i) => flags[i]);
}
@@ -0,0 +1,35 @@
import { useEffect, useState } from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import { getRequestTypesCached } from './approvalRoute.service';
import { accessibleAllRequestsTypes } from './requestTypeAccess';
/**
* pageUrl (formKey формы) → числовой RequestTypeId approval-route, только для
* типов, чьи заявки текущему пользователю разрешено видеть целиком (см.
* requestTypeAccess.ts). Один поход по всем типам разом — плитки и кнопка на
* открытой форме (SubmitFeature) решают по нему, показывать ли переход в
* «Все заявки», не дожидаясь резолва конкретного типа.
*
* undefined — ответ ещё не пришёл (плитки без кнопки, а не с ошибочно пустой).
*/
export function useAccessibleAllRequestsFormKeys(context: WebPartContext): Map<string, number> | undefined {
const [map, setMap] = useState<Map<string, number> | undefined>(undefined);
useEffect(() => {
let cancelled = false;
getRequestTypesCached(context)
.then((types) => accessibleAllRequestsTypes(context, types))
.then((accessible) => {
if (cancelled) return;
setMap(new Map(accessible.map((t) => [t.pageUrl, t.id])));
})
.catch(() => {
if (!cancelled) setMap(new Map());
});
return () => {
cancelled = true;
};
}, [context]);
return map;
}
+59 -4
View File
@@ -41,7 +41,7 @@ export type PortalMode = 'submit' | 'approve' | 'create' | 'edit';
* «Возвращена на доработку»); в отличие от первых двух смотрит
* на список данных по автору, а не на строки маршрута.
*/
export type NavSection = 'new' | 'approvals' | 'execution' | 'rework' | 'own' | 'search' | 'reports';
export type NavSection = 'new' | 'approvals' | 'execution' | 'rework' | 'own' | 'search' | 'reports' | 'allRequests';
/**
* Подвкладка раздела «Мои заявки». 'created' — заявки, где пользователь автор
@@ -90,6 +90,12 @@ export interface IRoute {
ownTab?: OwnTab;
/** Фильтр по статусу заявки; задан только при nav='own' и ownTab='created'. */
ownStatus?: string;
/**
* Выбранный тип заявки в разделе «Все заявки» (nav='allRequests') — числовой
* RequestTypeId approval-route. Не задан — раздел сам выберет тип (первый
* доступный текущему пользователю, см. AllRequestsFeature).
*/
allTypeId?: number;
/** Номер страницы таблицы (1-based). 1 — значение по умолчанию, в адрес не пишется. */
page?: number;
/** Текст поиска по таблице. Пустой — значение по умолчанию, в адрес не пишется. */
@@ -283,6 +289,7 @@ function readStoredNav(): NavSection {
raw === 'own' ||
raw === 'search' ||
raw === 'reports' ||
raw === 'allRequests' ||
raw === 'new'
? raw
: 'new';
@@ -291,6 +298,37 @@ function readStoredNav(): NavSection {
}
}
// Выбранный тип заявки в разделе «Все заявки» — тем же приёмом, что и
// подвкладка/фильтр «Моих заявок» (OWN_TAB_STORAGE_KEY выше): переживает
// перезагрузку страницы и новый визит, но не более того. Без этого переход в
// другой раздел и обратно (клик по пункту меню, без rpalltype в адресе)
// каждый раз сбрасывал бы выбор на первый доступный тип.
const ALL_TYPE_STORAGE_KEY = 'rp-all-type';
function readStoredAllTypeId(): number | undefined {
try {
const raw = window.localStorage.getItem(ALL_TYPE_STORAGE_KEY);
const id = Number(raw);
return raw && Number.isInteger(id) && id > 0 ? id : undefined;
} catch {
return undefined;
}
}
/** Запомненный тип «Все заявки» — как и storedOwnTab, нужен там, где маршрут СТРОИТСЯ. */
export function storedAllTypeId(): number | undefined {
return readStoredAllTypeId();
}
/** Запомнить тип «Все заявки» — вызывать только при явном выборе пользователя. */
export function persistAllTypeId(typeId: number): void {
try {
window.localStorage.setItem(ALL_TYPE_STORAGE_KEY, String(typeId));
} catch {
// localStorage недоступен (приватный режим и т.п.) — просто не сохраняем.
}
}
/** Запомнить раздел бокового меню — вызывать только при явном выборе пользователя. */
export function persistNav(nav: NavSection): void {
try {
@@ -308,6 +346,7 @@ function parseNav(raw: string | undefined): NavSection {
raw === 'own' ||
raw === 'search' ||
raw === 'reports' ||
raw === 'allRequests' ||
raw === 'new'
) {
return raw;
@@ -347,6 +386,18 @@ export function parseRoute(search: string = window.location.search): IRoute {
const ownTab = nav === 'own' ? parseOwnTab(params.get('rptab') ?? undefined) : undefined;
const ownStatus = ownTab === 'created' ? params.get('rpstatus') ?? readStoredOwnStatus() : undefined;
// Тип заявки в разделе «Все заявки» — из адреса, иначе запомненный (та же
// логика, что у ownTab/ownStatus выше). Если ни того, ни другого нет (первый
// визит) — undefined, и раздел сам выберет первый доступный тип (см.
// AllRequestsFeature), а как только он выберет — маршрут и localStorage
// подхватят выбор (см. gotoAllRequestsType в RequestPortal.tsx).
const allTypeIdParam = nav === 'allRequests' ? params.get('rpalltype') : null;
const allTypeIdFromUrl = allTypeIdParam ? Number(allTypeIdParam) : NaN;
const allTypeId =
nav === 'allRequests'
? (Number.isInteger(allTypeIdFromUrl) && allTypeIdFromUrl > 0 ? allTypeIdFromUrl : readStoredAllTypeId())
: undefined;
// Страница: из адреса, иначе — запомненная для ЭТОЙ таблицы. Мусор в
// параметре (0, минус, буквы) трактуем как первую страницу, а не как ошибку:
// ссылка приходит извне, ронять из-за неё раздел незачем.
@@ -362,11 +413,11 @@ export function parseRoute(search: string = window.location.search): IRoute {
// страницей: закрыв панель, пользователь должен вернуться ровно в тот же
// список и на то же место в нём.
if (mode === 'approve' && formKey && itemId && !isNaN(itemId)) {
return { mode: 'approve', nav, formKey, itemId, listId, ownTab, ownStatus, page, query };
return { mode: 'approve', nav, formKey, itemId, listId, ownTab, ownStatus, allTypeId, page, query };
}
// Редактирование собственной незавершённой заявки — та же панель, но с формой.
if (mode === 'edit' && formKey && itemId && !isNaN(itemId)) {
return { mode: 'edit', nav, formKey, itemId, listId, ownTab, ownStatus, page, query };
return { mode: 'edit', nav, formKey, itemId, listId, ownTab, ownStatus, allTypeId, page, query };
}
// Создание производной заявки с предзаполнением из исходной.
if (formKey && sourceId && !isNaN(sourceId)) {
@@ -383,7 +434,7 @@ export function parseRoute(search: string = window.location.search): IRoute {
if (formKey && mode !== 'approve' && mode !== 'edit') {
return { mode: 'create', nav: 'new', formKey, positionId, nomenclatureId, storeName };
}
return { mode: 'submit', nav, ownTab, ownStatus, page, query };
return { mode: 'submit', nav, ownTab, ownStatus, allTypeId, page, query };
}
// Собрать query-строку маршрута, сохранив посторонние параметры страницы.
@@ -399,6 +450,7 @@ function buildQuery(route: IRoute): string {
params.delete('rpstatus');
params.delete('rppage');
params.delete('rpq');
params.delete('rpalltype');
if (route.nav !== 'new') {
params.set('rpnav', route.nav);
@@ -410,6 +462,9 @@ function buildQuery(route: IRoute): string {
if (ownTab !== 'created') params.set('rptab', ownTab);
else if (route.ownStatus) params.set('rpstatus', route.ownStatus);
}
if (route.nav === 'allRequests' && route.allTypeId) {
params.set('rpalltype', String(route.allTypeId));
}
// Первая страница — тоже значение по умолчанию (см. выше про чистую ссылку).
if (route.page && route.page > 1) params.set('rppage', String(route.page));
if (route.query) params.set('rpq', route.query);