Feature/request type access (#23)
Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz> Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/23
This commit was merged in pull request #23.
This commit is contained in:
@@ -14,6 +14,7 @@ import { texts } from '../../texts';
|
||||
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
|
||||
import { DashboardScreen } from '../screens/DashboardScreen';
|
||||
import { RequestTypesScreen } from '../screens/RequestTypesScreen';
|
||||
import { RequestTypeAccessScreen } from '../screens/RequestTypeAccessScreen';
|
||||
import { RoutesScreen } from '../screens/RoutesScreen';
|
||||
import { RouteStepsScreen } from '../screens/RouteStepsScreen';
|
||||
import { ResolveScreen } from '../screens/ResolveScreen';
|
||||
@@ -27,6 +28,7 @@ import { ManageByUserScreen } from '../screens/ManageByUserScreen';
|
||||
const NAV_ITEMS: ISidebarItem[] = [
|
||||
{ id: 'dashboard', title: texts.nav.dashboard, icon: 'chart' },
|
||||
{ id: 'requestTypes', title: texts.nav.requestTypes, icon: 'document' },
|
||||
{ id: 'requestTypeAccess', title: texts.nav.requestTypeAccess, icon: 'people' },
|
||||
{ id: 'routes', title: texts.nav.routes, icon: 'send' },
|
||||
{ id: 'resolve', title: texts.nav.resolve, icon: 'search' },
|
||||
{ id: 'manageRequests', title: texts.nav.manageRequests, icon: 'clipboard' },
|
||||
@@ -147,6 +149,14 @@ export const AdminApp: React.FC<IAdminAppProps> = ({ api, dictionary, sharePoint
|
||||
<div className={styles.content}>
|
||||
{route.tab === 'dashboard' && <DashboardScreen api={api} onNavigate={navigateTab} />}
|
||||
{route.tab === 'requestTypes' && <RequestTypesScreen api={api} sharePoint={sharePoint} />}
|
||||
{route.tab === 'requestTypeAccess' && (
|
||||
<RequestTypeAccessScreen
|
||||
api={api}
|
||||
dictionary={dictionary}
|
||||
initialFilterRequestTypeId={route.accessFilterRequestTypeId}
|
||||
initialFilterEmail={route.accessFilterEmail}
|
||||
/>
|
||||
)}
|
||||
{route.tab === 'routes' && !route.routeId && (
|
||||
<RoutesScreen
|
||||
api={api}
|
||||
|
||||
@@ -1 +1,9 @@
|
||||
export type ScreenKey = 'dashboard' | 'requestTypes' | 'routes' | 'routeSteps' | 'resolve' | 'manageRequests' | 'manageByUser';
|
||||
export type ScreenKey =
|
||||
| 'dashboard'
|
||||
| 'requestTypes'
|
||||
| 'requestTypeAccess'
|
||||
| 'routes'
|
||||
| 'routeSteps'
|
||||
| 'resolve'
|
||||
| 'manageRequests'
|
||||
| 'manageByUser';
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
.filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 18px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.filterField {
|
||||
min-width: 220px;
|
||||
max-width: 320px;
|
||||
flex: 1;
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import * as React from 'react';
|
||||
import styles from './RequestTypeAccessScreen.module.scss';
|
||||
import { ApprovalRouteApi } from '../../services/api';
|
||||
import { PortalDictionaryClient } from '../../services/portalDictionary';
|
||||
import { texts } from '../../texts';
|
||||
import { useRequestTypes } from '../../hooks/useRequestTypes';
|
||||
import { useRequestTypeAccess } from '../../hooks/useRequestTypeAccess';
|
||||
import { usePortalEmployees } from '../../hooks/usePortalEmployees';
|
||||
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
|
||||
import { useDeleteTarget } from '../../hooks/useDeleteTarget';
|
||||
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
|
||||
import { toEmployeeOption } from '../../utils/employee';
|
||||
import { replaceRoute } from '../../routing';
|
||||
import { RequestTypeAccess } from '../../models';
|
||||
import {
|
||||
ActionsCell,
|
||||
Alert,
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
ConfirmDialog,
|
||||
DataTable,
|
||||
Field,
|
||||
FormGrid,
|
||||
FormModal,
|
||||
IconButton,
|
||||
PageHeader,
|
||||
PrimaryCell,
|
||||
SearchableSelect,
|
||||
Select
|
||||
} from '../ui';
|
||||
|
||||
export interface IRequestTypeAccessScreenProps {
|
||||
api: ApprovalRouteApi;
|
||||
dictionary?: PortalDictionaryClient;
|
||||
/** Фильтры из ссылки (aaacctype/aaaccemail) — перекрывают сохранённые, если заданы. */
|
||||
initialFilterRequestTypeId?: string;
|
||||
initialFilterEmail?: string;
|
||||
}
|
||||
|
||||
// Фильтры переживают перезагрузку — тот же приём, что и ROUTES_FILTER_STORAGE_KEY в RoutesScreen.
|
||||
const ACCESS_FILTER_TYPE_STORAGE_KEY = 'approvalAdmin.accessFilterRequestTypeId';
|
||||
const ACCESS_FILTER_EMAIL_STORAGE_KEY = 'approvalAdmin.accessFilterEmail';
|
||||
|
||||
export const RequestTypeAccessScreen: React.FC<IRequestTypeAccessScreenProps> = ({
|
||||
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<RequestTypeAccess>(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 (
|
||||
<div>
|
||||
<Breadcrumbs items={[{ label: texts.common.home }, { label: texts.requestTypeAccess.breadcrumb }]} />
|
||||
<PageHeader
|
||||
icon="people"
|
||||
subtitle={texts.requestTypeAccess.subtitle}
|
||||
title={texts.requestTypeAccess.title}
|
||||
actions={<Button variant="accent" icon="plus" onClick={openCreate}>{texts.requestTypeAccess.add}</Button>}
|
||||
/>
|
||||
|
||||
<div className={styles.filters}>
|
||||
<div className={styles.filterField}>
|
||||
<Field label={texts.requestTypeAccess.filterRequestTypeLabel}>
|
||||
<Select
|
||||
options={requestTypes.items.map(rt => ({ value: String(rt.id), label: rt.name }))}
|
||||
placeholder={texts.requestTypeAccess.filterRequestTypeAll}
|
||||
value={filterRequestTypeId}
|
||||
onChange={e => setFilterRequestTypeId(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div className={styles.filterField}>
|
||||
<Field label={texts.requestTypeAccess.filterEmailLabel}>
|
||||
<SearchableSelect
|
||||
options={employees.items.map(toEmployeeOption)}
|
||||
value={filterEmail}
|
||||
loading={employees.isLoading}
|
||||
allowClear
|
||||
clearLabel={texts.requestTypeAccess.filterEmailAll}
|
||||
placeholder={texts.requestTypeAccess.filterEmailPlaceholder}
|
||||
searchPlaceholder={texts.select.employeeSearchPlaceholder}
|
||||
emptyMessage={texts.select.employeesEmpty}
|
||||
onChange={setFilterEmail}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{access.error && <Alert kind="err">{access.error}</Alert>}
|
||||
{deleteTarget.error && <Alert kind="err">{deleteTarget.error}</Alert>}
|
||||
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'requestType', header: texts.requestTypeAccess.columnRequestType, render: r => <PrimaryCell>{requestTypeName(r.requestTypeId)}</PrimaryCell> },
|
||||
{ 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 => (
|
||||
<ActionsCell>
|
||||
<IconButton icon="trash" label={texts.common.remove} danger onClick={() => deleteTarget.ask(r)} />
|
||||
</ActionsCell>
|
||||
)
|
||||
}
|
||||
]}
|
||||
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 && (
|
||||
<FormModal
|
||||
title={texts.requestTypeAccess.createTitle}
|
||||
error={submit.error}
|
||||
isSaving={submit.isBusy}
|
||||
onSubmit={submitForm}
|
||||
onClose={() => setShowForm(false)}
|
||||
>
|
||||
<FormGrid>
|
||||
<Field label={texts.requestTypeAccess.requestTypeLabel} required full>
|
||||
<Select
|
||||
options={requestTypes.items.map(rt => ({ value: String(rt.id), label: rt.name }))}
|
||||
placeholder={texts.requestTypeAccess.requestTypePlaceholder}
|
||||
value={requestTypeId}
|
||||
onChange={e => setRequestTypeId(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={texts.requestTypeAccess.emailLabel} required full>
|
||||
<SearchableSelect
|
||||
options={employees.items.map(toEmployeeOption)}
|
||||
value={email}
|
||||
loading={employees.isLoading}
|
||||
placeholder={texts.requestTypeAccess.emailPlaceholder}
|
||||
searchPlaceholder={texts.select.employeeSearchPlaceholder}
|
||||
emptyMessage={texts.select.employeesEmpty}
|
||||
onChange={setEmail}
|
||||
/>
|
||||
</Field>
|
||||
</FormGrid>
|
||||
</FormModal>
|
||||
)}
|
||||
|
||||
{deleteTarget.target && (
|
||||
<ConfirmDialog
|
||||
title={texts.requestTypeAccess.deleteTitle}
|
||||
text={texts.requestTypeAccess.deleteText(deleteTarget.target.email, requestTypeName(deleteTarget.target.requestTypeId))}
|
||||
confirmLabel={texts.common.remove}
|
||||
busy={deleteTarget.isBusy}
|
||||
onCancel={deleteTarget.cancel}
|
||||
onConfirm={deleteTarget.confirm}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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<RequestTypeAccess> {
|
||||
const load = React.useCallback(() => api.requestTypeAccess.list(), [api]);
|
||||
return useAsyncList(load);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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<RequestListPartition[]>(`/api/request-types/${requestTypeId}/lists`)
|
||||
};
|
||||
|
||||
// Доступ «видеть все заявки типа» (Application/RequestTypeAccesses/*.cs) —
|
||||
// тот же список email по типу, что и ручка для Power Automate
|
||||
// (GET .../{requestTypeId}/emails), только здесь читается ВЕСЬ реестр разом
|
||||
// (requestTypeId не передан) — админке нужна одна таблица по всем типам.
|
||||
public readonly requestTypeAccess = {
|
||||
list: (requestTypeId?: number): Promise<RequestTypeAccess[]> =>
|
||||
this.http.get<RequestTypeAccess[]>('/api/request-type-access', { requestTypeId }),
|
||||
|
||||
create: (body: CreateRequestTypeAccessInput): Promise<RequestTypeAccess> =>
|
||||
this.http.post<RequestTypeAccess>('/api/request-type-access', body),
|
||||
|
||||
remove: (id: number): Promise<void> =>
|
||||
this.http.delete(`/api/request-type-access/${id}`)
|
||||
};
|
||||
|
||||
public readonly templateRoutes = {
|
||||
list: (requestTypeId?: number): Promise<TemplateRoute[]> =>
|
||||
this.http.get<TemplateRoute[]>('/api/template-routes', { requestTypeId }),
|
||||
|
||||
@@ -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: 'Настройка согласований',
|
||||
|
||||
Reference in New Issue
Block a user