{route.tab === 'dashboard' &&
}
{route.tab === 'requestTypes' &&
}
+ {route.tab === 'requestTypeAccess' && (
+
+ )}
{route.tab === 'routes' && !route.routeId && (
= ({
+ api,
+ dictionary,
+ initialFilterRequestTypeId,
+ initialFilterEmail
+}) => {
+ const requestTypes = useRequestTypes(api);
+ const access = useRequestTypeAccess(api);
+ const employees = usePortalEmployees(dictionary);
+
+ const [showForm, setShowForm] = React.useState(false);
+ const [requestTypeId, setRequestTypeId] = React.useState('');
+ const [email, setEmail] = React.useState('');
+ const submit = useAsyncSubmit();
+
+ const [filterRequestTypeId, setFilterRequestTypeId] = useLocalStorageState(ACCESS_FILTER_TYPE_STORAGE_KEY, '');
+ const [filterEmail, setFilterEmail] = useLocalStorageState(ACCESS_FILTER_EMAIL_STORAGE_KEY, '');
+
+ // Ссылка с фильтром сильнее сохранённого значения — только на монтировании
+ // (см. тот же приём в RoutesScreen).
+ React.useEffect(() => {
+ if (initialFilterRequestTypeId) setFilterRequestTypeId(initialFilterRequestTypeId);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+ React.useEffect(() => {
+ if (initialFilterEmail) setFilterEmail(initialFilterEmail);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ // Держим адрес синхронным с фильтрами — отфильтрованную таблицу можно
+ // передать ссылкой. replaceRoute, а не navigate: смена фильтра не должна
+ // плодить записи в истории браузера.
+ React.useEffect(() => {
+ replaceRoute({ tab: 'requestTypeAccess', accessFilterRequestTypeId: filterRequestTypeId, accessFilterEmail: filterEmail });
+ }, [filterRequestTypeId, filterEmail]);
+
+ const deleteTarget = useDeleteTarget(item => api.requestTypeAccess.remove(item.id), access.reload);
+
+ const requestTypeName = (id: number): string => requestTypes.items.find(rt => rt.id === id)?.name ?? `#${id}`;
+
+ const filteredItems = access.items.filter(item => {
+ if (filterRequestTypeId && String(item.requestTypeId) !== filterRequestTypeId) return false;
+ if (filterEmail && item.email.toLowerCase() !== filterEmail.toLowerCase()) return false;
+ return true;
+ });
+
+ // Список SharePoint нужно вручную открыть на чтение этому email — иначе доступ
+ // здесь настраивает только видимость в UI approval-route, но не в самом
+ // SharePoint (поиск по порталу отфильтрует чужие заявки правами списка).
+ const formatSharePointLists = (item: RequestTypeAccess): string =>
+ item.sharePointLists.length === 0
+ ? texts.common.empty
+ : item.sharePointLists.map(l => l.title ?? l.listId).join(', ');
+
+ const openCreate = (): void => {
+ setRequestTypeId('');
+ setEmail('');
+ setShowForm(true);
+ };
+
+ const submitForm = (): void => {
+ if (!requestTypeId) {
+ submit.fail(texts.requestTypeAccess.validationRequestType);
+ return;
+ }
+ if (!email.trim()) {
+ submit.fail(texts.requestTypeAccess.validationEmail);
+ return;
+ }
+
+ submit.run(() =>
+ api.requestTypeAccess.create({ requestTypeId: Number(requestTypeId), email: email.trim() }).then(() => {
+ setShowForm(false);
+ access.reload();
+ })
+ );
+ };
+
+ return (
+
+
+
{texts.requestTypeAccess.add}}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {access.error && {access.error}}
+ {deleteTarget.error && {deleteTarget.error}}
+
+ {requestTypeName(r.requestTypeId)} },
+ { key: 'email', header: texts.requestTypeAccess.columnEmail, render: r => r.email },
+ { key: 'sharePointLists', header: texts.requestTypeAccess.columnSharePointLists, render: formatSharePointLists },
+ {
+ key: 'actions',
+ header: '',
+ width: '100px',
+ align: 'right',
+ sticky: true,
+ render: r => (
+
+ deleteTarget.ask(r)} />
+
+ )
+ }
+ ]}
+ rows={filteredItems}
+ keyOf={r => r.id}
+ emptyMessage={
+ access.isLoading
+ ? texts.common.loading
+ : access.items.length > 0 && filteredItems.length === 0
+ ? texts.requestTypeAccess.emptyFiltered
+ : texts.requestTypeAccess.emptyList
+ }
+ />
+
+ {showForm && (
+ setShowForm(false)}
+ >
+
+
+
+
+
+
+
+
+ )}
+
+ {deleteTarget.target && (
+
+ )}
+
+ );
+};
diff --git a/frontend/src/webparts/approvalAdmin/hooks/useRequestTypeAccess.ts b/frontend/src/webparts/approvalAdmin/hooks/useRequestTypeAccess.ts
new file mode 100644
index 0000000..294765a
--- /dev/null
+++ b/frontend/src/webparts/approvalAdmin/hooks/useRequestTypeAccess.ts
@@ -0,0 +1,10 @@
+import * as React from 'react';
+import { RequestTypeAccess } from '../models';
+import { ApprovalRouteApi } from '../services/api';
+import { AsyncList, useAsyncList } from './useAsyncList';
+
+/** Реестр доступа «видеть все заявки типа» — целиком, по всем типам заявок. */
+export function useRequestTypeAccess(api: ApprovalRouteApi): AsyncList {
+ const load = React.useCallback(() => api.requestTypeAccess.list(), [api]);
+ return useAsyncList(load);
+}
diff --git a/frontend/src/webparts/approvalAdmin/models/index.ts b/frontend/src/webparts/approvalAdmin/models/index.ts
index 2a19b8d..4c8e0dc 100644
--- a/frontend/src/webparts/approvalAdmin/models/index.ts
+++ b/frontend/src/webparts/approvalAdmin/models/index.ts
@@ -2,6 +2,7 @@
export * from './enums';
export * from './dictionaries';
export * from './requestTypes';
+export * from './requestTypeAccess';
export * from './requestTypeLists';
export * from './templateRoutes';
export * from './templateRouteSteps';
diff --git a/frontend/src/webparts/approvalAdmin/models/requestTypeAccess.ts b/frontend/src/webparts/approvalAdmin/models/requestTypeAccess.ts
new file mode 100644
index 0000000..dfacc02
--- /dev/null
+++ b/frontend/src/webparts/approvalAdmin/models/requestTypeAccess.ts
@@ -0,0 +1,20 @@
+// DTO эндпоинта api/request-type-access (Application/RequestTypeAccesses/*.cs).
+import { RequestListPartition } from './requestTypeLists';
+
+export interface RequestTypeAccess {
+ id: number;
+ requestTypeId: number;
+ email: string;
+ /**
+ * Вся серия SharePoint-списков данных типа заявки (архив + текущая партиция) —
+ * именно на них нужно вручную выдать email'у право чтения, иначе поиск по
+ * порталу продолжит отдавать только его собственные заявки: этот реестр
+ * настраивает видимость в UI approval-route, а не права SharePoint.
+ */
+ sharePointLists: RequestListPartition[];
+}
+
+export interface CreateRequestTypeAccessInput {
+ requestTypeId: number;
+ email: string;
+}
diff --git a/frontend/src/webparts/approvalAdmin/routing.ts b/frontend/src/webparts/approvalAdmin/routing.ts
index dd0d996..fe8ba58 100644
--- a/frontend/src/webparts/approvalAdmin/routing.ts
+++ b/frontend/src/webparts/approvalAdmin/routing.ts
@@ -69,6 +69,14 @@ export interface IAdminRoute {
* к тому же отфильтрованному списку, а не сбросить фильтр.
*/
routesFilter?: string;
+ /**
+ * Фильтры таблицы «Доступ к заявкам» (id типа как строка, email) — только при
+ * tab='requestTypeAccess'. Та же логика, что и у routesFilter: живут в адресе,
+ * чтобы отфильтрованную ссылку можно было передать и чтобы фильтр не терялся
+ * при возврате на вкладку.
+ */
+ accessFilterRequestTypeId?: string;
+ accessFilterEmail?: string;
/** Задан только при tab='resolve' и непустом requestTypeId. */
resolve?: IResolveRouteParams;
/** Задан только при tab='manageRequests' и непустых requestTypeId+requestId. */
@@ -81,10 +89,11 @@ const ROUTE_CHANGE_EVENT = 'aa-route-change';
// 'routeSteps' сюда намеренно не входит — это не отдельная вкладка URL, а
// состояние вкладки 'routes' при наличии aaroute (см. parseRoute).
-const TABS: ScreenKey[] = ['dashboard', 'requestTypes', 'routes', 'resolve', 'manageRequests', 'manageByUser'];
+const TABS: ScreenKey[] = ['dashboard', 'requestTypes', 'requestTypeAccess', 'routes', 'resolve', 'manageRequests', 'manageByUser'];
const PARAM_KEYS = [
'aatab', 'aaroute', 'aarfilter',
+ 'aaacctype', 'aaaccemail',
'aartype', 'aauser', 'aaproject', 'aaprojectname', 'aastatus', 'aafields', 'aadept',
'aamtype', 'aamreq', 'aamlist',
'aabuser', 'aabtarget'
@@ -146,6 +155,8 @@ export function parseRoute(search: string = window.location.search): IAdminRoute
tab,
routeId: routeId && !isNaN(routeId) ? routeId : undefined,
routesFilter: (tab === 'routes' ? params.get('aarfilter') : null) || undefined,
+ accessFilterRequestTypeId: (tab === 'requestTypeAccess' ? params.get('aaacctype') : null) || undefined,
+ accessFilterEmail: (tab === 'requestTypeAccess' ? params.get('aaaccemail') : null) || undefined,
resolve,
manage,
manageByUser: byUserEmail
@@ -167,6 +178,11 @@ function buildQuery(route: IAdminRoute): string {
if (route.routesFilter) params.set('aarfilter', route.routesFilter);
}
+ if (route.tab === 'requestTypeAccess') {
+ if (route.accessFilterRequestTypeId) params.set('aaacctype', route.accessFilterRequestTypeId);
+ if (route.accessFilterEmail) params.set('aaaccemail', route.accessFilterEmail);
+ }
+
if (route.tab === 'resolve' && route.resolve && route.resolve.requestTypeId) {
params.set('aartype', route.resolve.requestTypeId);
if (route.resolve.userEmail) params.set('aauser', route.resolve.userEmail);
diff --git a/frontend/src/webparts/approvalAdmin/services/api.ts b/frontend/src/webparts/approvalAdmin/services/api.ts
index 91e57d5..05b04de 100644
--- a/frontend/src/webparts/approvalAdmin/services/api.ts
+++ b/frontend/src/webparts/approvalAdmin/services/api.ts
@@ -1,5 +1,6 @@
import { IHttpClient } from './httpClient';
import {
+ CreateRequestTypeAccessInput,
CreateRequestTypeInput,
CreateTemplateRouteInput,
CreateTemplateRouteStepInput,
@@ -13,6 +14,7 @@ import {
PatchTemplateRouteStepInput,
RequestListPartition,
RequestType,
+ RequestTypeAccess,
RequestTypeListSeries,
ResolveTemplateRouteQuery,
ResolveTemplateRouteResult,
@@ -80,6 +82,21 @@ export class ApprovalRouteApi {
this.http.get(`/api/request-types/${requestTypeId}/lists`)
};
+ // Доступ «видеть все заявки типа» (Application/RequestTypeAccesses/*.cs) —
+ // тот же список email по типу, что и ручка для Power Automate
+ // (GET .../{requestTypeId}/emails), только здесь читается ВЕСЬ реестр разом
+ // (requestTypeId не передан) — админке нужна одна таблица по всем типам.
+ public readonly requestTypeAccess = {
+ list: (requestTypeId?: number): Promise =>
+ this.http.get('/api/request-type-access', { requestTypeId }),
+
+ create: (body: CreateRequestTypeAccessInput): Promise =>
+ this.http.post('/api/request-type-access', body),
+
+ remove: (id: number): Promise =>
+ this.http.delete(`/api/request-type-access/${id}`)
+ };
+
public readonly templateRoutes = {
list: (requestTypeId?: number): Promise =>
this.http.get('/api/template-routes', { requestTypeId }),
diff --git a/frontend/src/webparts/approvalAdmin/texts.ts b/frontend/src/webparts/approvalAdmin/texts.ts
index 04f8a73..b6048f2 100644
--- a/frontend/src/webparts/approvalAdmin/texts.ts
+++ b/frontend/src/webparts/approvalAdmin/texts.ts
@@ -93,6 +93,7 @@ export const texts = {
title: 'Панель настройки маршрутов',
dashboard: 'Дашборд',
requestTypes: 'Типы заявок',
+ requestTypeAccess: 'Доступ к заявкам',
routes: 'Маршруты',
resolve: 'Проверка маршрута',
manageRequests: 'Редактирование заявок',
@@ -178,6 +179,33 @@ export const texts = {
`Тип «${name}» будет удалён безвозвратно. Связанные маршруты могут перестать разрешаться.`
},
+ requestTypeAccess: {
+ breadcrumb: 'Доступ к заявкам',
+ subtitle: 'Кто видит все заявки типа, а не только свои',
+ title: 'Доступ к заявкам',
+ add: 'Выдать доступ',
+ emptyList: 'Пока никому не выдан доступ ни к одному типу заявок.',
+ emptyFiltered: 'Ничего не найдено по заданным фильтрам.',
+ filterRequestTypeLabel: 'Фильтр по типу заявки',
+ filterRequestTypeAll: 'Все типы заявок',
+ filterEmailLabel: 'Фильтр по сотруднику',
+ filterEmailAll: 'Все сотрудники',
+ filterEmailPlaceholder: 'Найдите по ФИО или email',
+ columnRequestType: 'Тип заявки',
+ columnEmail: 'Сотрудник',
+ columnSharePointLists: 'Списки SharePoint (выдать доступ вручную)',
+ createTitle: 'Выдать доступ',
+ requestTypeLabel: 'Тип заявки',
+ requestTypePlaceholder: 'Выберите тип заявки',
+ emailLabel: 'Сотрудник',
+ emailPlaceholder: 'Найдите по ФИО или email',
+ validationRequestType: 'Выберите тип заявки.',
+ validationEmail: 'Выберите сотрудника.',
+ deleteTitle: 'Отозвать доступ?',
+ deleteText: (email: string, requestTypeName: string): string =>
+ `${email} больше не будет видеть все заявки типа «${requestTypeName}».`
+ },
+
routes: {
breadcrumb: 'Маршруты согласования',
subtitle: 'Настройка согласований',