Feature/request type access #23
@@ -14,6 +14,7 @@ import { texts } from '../../texts';
|
|||||||
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
|
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
|
||||||
import { DashboardScreen } from '../screens/DashboardScreen';
|
import { DashboardScreen } from '../screens/DashboardScreen';
|
||||||
import { RequestTypesScreen } from '../screens/RequestTypesScreen';
|
import { RequestTypesScreen } from '../screens/RequestTypesScreen';
|
||||||
|
import { RequestTypeAccessScreen } from '../screens/RequestTypeAccessScreen';
|
||||||
import { RoutesScreen } from '../screens/RoutesScreen';
|
import { RoutesScreen } from '../screens/RoutesScreen';
|
||||||
import { RouteStepsScreen } from '../screens/RouteStepsScreen';
|
import { RouteStepsScreen } from '../screens/RouteStepsScreen';
|
||||||
import { ResolveScreen } from '../screens/ResolveScreen';
|
import { ResolveScreen } from '../screens/ResolveScreen';
|
||||||
@@ -27,6 +28,7 @@ import { ManageByUserScreen } from '../screens/ManageByUserScreen';
|
|||||||
const NAV_ITEMS: ISidebarItem[] = [
|
const NAV_ITEMS: ISidebarItem[] = [
|
||||||
{ id: 'dashboard', title: texts.nav.dashboard, icon: 'chart' },
|
{ id: 'dashboard', title: texts.nav.dashboard, icon: 'chart' },
|
||||||
{ id: 'requestTypes', title: texts.nav.requestTypes, icon: 'document' },
|
{ 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: 'routes', title: texts.nav.routes, icon: 'send' },
|
||||||
{ id: 'resolve', title: texts.nav.resolve, icon: 'search' },
|
{ id: 'resolve', title: texts.nav.resolve, icon: 'search' },
|
||||||
{ id: 'manageRequests', title: texts.nav.manageRequests, icon: 'clipboard' },
|
{ id: 'manageRequests', title: texts.nav.manageRequests, icon: 'clipboard' },
|
||||||
@@ -147,6 +149,7 @@ export const AdminApp: React.FC<IAdminAppProps> = ({ api, dictionary, sharePoint
|
|||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
{route.tab === 'dashboard' && <DashboardScreen api={api} onNavigate={navigateTab} />}
|
{route.tab === 'dashboard' && <DashboardScreen api={api} onNavigate={navigateTab} />}
|
||||||
{route.tab === 'requestTypes' && <RequestTypesScreen api={api} sharePoint={sharePoint} />}
|
{route.tab === 'requestTypes' && <RequestTypesScreen api={api} sharePoint={sharePoint} />}
|
||||||
|
{route.tab === 'requestTypeAccess' && <RequestTypeAccessScreen api={api} dictionary={dictionary} />}
|
||||||
{route.tab === 'routes' && !route.routeId && (
|
{route.tab === 'routes' && !route.routeId && (
|
||||||
<RoutesScreen
|
<RoutesScreen
|
||||||
api={api}
|
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';
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
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 { toEmployeeOption } from '../../utils/employee';
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RequestTypeAccessScreen: React.FC<IRequestTypeAccessScreenProps> = ({ api, dictionary }) => {
|
||||||
|
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 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 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>}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{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: '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={access.items}
|
||||||
|
keyOf={r => r.id}
|
||||||
|
emptyMessage={access.isLoading ? texts.common.loading : 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 './enums';
|
||||||
export * from './dictionaries';
|
export * from './dictionaries';
|
||||||
export * from './requestTypes';
|
export * from './requestTypes';
|
||||||
|
export * from './requestTypeAccess';
|
||||||
export * from './requestTypeLists';
|
export * from './requestTypeLists';
|
||||||
export * from './templateRoutes';
|
export * from './templateRoutes';
|
||||||
export * from './templateRouteSteps';
|
export * from './templateRouteSteps';
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
// DTO эндпоинта api/request-type-access (Application/RequestTypeAccesses/*.cs).
|
||||||
|
|
||||||
|
export interface RequestTypeAccess {
|
||||||
|
id: number;
|
||||||
|
requestTypeId: number;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateRequestTypeAccessInput {
|
||||||
|
requestTypeId: number;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
@@ -81,7 +81,7 @@ const ROUTE_CHANGE_EVENT = 'aa-route-change';
|
|||||||
|
|
||||||
// 'routeSteps' сюда намеренно не входит — это не отдельная вкладка URL, а
|
// 'routeSteps' сюда намеренно не входит — это не отдельная вкладка URL, а
|
||||||
// состояние вкладки 'routes' при наличии aaroute (см. parseRoute).
|
// состояние вкладки '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 = [
|
const PARAM_KEYS = [
|
||||||
'aatab', 'aaroute', 'aarfilter',
|
'aatab', 'aaroute', 'aarfilter',
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { IHttpClient } from './httpClient';
|
import { IHttpClient } from './httpClient';
|
||||||
import {
|
import {
|
||||||
|
CreateRequestTypeAccessInput,
|
||||||
CreateRequestTypeInput,
|
CreateRequestTypeInput,
|
||||||
CreateTemplateRouteInput,
|
CreateTemplateRouteInput,
|
||||||
CreateTemplateRouteStepInput,
|
CreateTemplateRouteStepInput,
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
PatchTemplateRouteStepInput,
|
PatchTemplateRouteStepInput,
|
||||||
RequestListPartition,
|
RequestListPartition,
|
||||||
RequestType,
|
RequestType,
|
||||||
|
RequestTypeAccess,
|
||||||
RequestTypeListSeries,
|
RequestTypeListSeries,
|
||||||
ResolveTemplateRouteQuery,
|
ResolveTemplateRouteQuery,
|
||||||
ResolveTemplateRouteResult,
|
ResolveTemplateRouteResult,
|
||||||
@@ -80,6 +82,21 @@ export class ApprovalRouteApi {
|
|||||||
this.http.get<RequestListPartition[]>(`/api/request-types/${requestTypeId}/lists`)
|
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 = {
|
public readonly templateRoutes = {
|
||||||
list: (requestTypeId?: number): Promise<TemplateRoute[]> =>
|
list: (requestTypeId?: number): Promise<TemplateRoute[]> =>
|
||||||
this.http.get<TemplateRoute[]>('/api/template-routes', { requestTypeId }),
|
this.http.get<TemplateRoute[]>('/api/template-routes', { requestTypeId }),
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ export const texts = {
|
|||||||
title: 'Панель настройки маршрутов',
|
title: 'Панель настройки маршрутов',
|
||||||
dashboard: 'Дашборд',
|
dashboard: 'Дашборд',
|
||||||
requestTypes: 'Типы заявок',
|
requestTypes: 'Типы заявок',
|
||||||
|
requestTypeAccess: 'Доступ к заявкам',
|
||||||
routes: 'Маршруты',
|
routes: 'Маршруты',
|
||||||
resolve: 'Проверка маршрута',
|
resolve: 'Проверка маршрута',
|
||||||
manageRequests: 'Редактирование заявок',
|
manageRequests: 'Редактирование заявок',
|
||||||
@@ -178,6 +179,26 @@ export const texts = {
|
|||||||
`Тип «${name}» будет удалён безвозвратно. Связанные маршруты могут перестать разрешаться.`
|
`Тип «${name}» будет удалён безвозвратно. Связанные маршруты могут перестать разрешаться.`
|
||||||
},
|
},
|
||||||
|
|
||||||
|
requestTypeAccess: {
|
||||||
|
breadcrumb: 'Доступ к заявкам',
|
||||||
|
subtitle: 'Кто видит все заявки типа, а не только свои',
|
||||||
|
title: 'Доступ к заявкам',
|
||||||
|
add: 'Выдать доступ',
|
||||||
|
emptyList: 'Пока никому не выдан доступ ни к одному типу заявок.',
|
||||||
|
columnRequestType: 'Тип заявки',
|
||||||
|
columnEmail: 'Сотрудник',
|
||||||
|
createTitle: 'Выдать доступ',
|
||||||
|
requestTypeLabel: 'Тип заявки',
|
||||||
|
requestTypePlaceholder: 'Выберите тип заявки',
|
||||||
|
emailLabel: 'Сотрудник',
|
||||||
|
emailPlaceholder: 'Найдите по ФИО или email',
|
||||||
|
validationRequestType: 'Выберите тип заявки.',
|
||||||
|
validationEmail: 'Выберите сотрудника.',
|
||||||
|
deleteTitle: 'Отозвать доступ?',
|
||||||
|
deleteText: (email: string, requestTypeName: string): string =>
|
||||||
|
`${email} больше не будет видеть все заявки типа «${requestTypeName}».`
|
||||||
|
},
|
||||||
|
|
||||||
routes: {
|
routes: {
|
||||||
breadcrumb: 'Маршруты согласования',
|
breadcrumb: 'Маршруты согласования',
|
||||||
subtitle: 'Настройка согласований',
|
subtitle: 'Настройка согласований',
|
||||||
|
|||||||
Reference in New Issue
Block a user