Feature/request type access #23

Merged
gorbenko.pavel merged 5 commits from feature/request-type-access into main 2026-09-25 11:48:03 +00:00
9 changed files with 225 additions and 2 deletions
Showing only changes of commit 58691216ed - Show all commits
@@ -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,7 @@ 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} />}
{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';
@@ -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 './dictionaries';
export * from './requestTypes';
export * from './requestTypeAccess';
export * from './requestTypeLists';
export * from './templateRoutes';
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, а
// состояние вкладки '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',
@@ -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,26 @@ export const texts = {
`Тип «${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: {
breadcrumb: 'Маршруты согласования',
subtitle: 'Настройка согласований',