Feature/request type access all requests (#22)
Tests / frontend-test (push) Successful in 1m24s
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:
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user