Feature/request type access #23
@@ -149,7 +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} />}
|
||||
{route.tab === 'requestTypeAccess' && (
|
||||
<RequestTypeAccessScreen
|
||||
api={api}
|
||||
dictionary={dictionary}
|
||||
initialFilterRequestTypeId={route.accessFilterRequestTypeId}
|
||||
initialFilterEmail={route.accessFilterEmail}
|
||||
/>
|
||||
)}
|
||||
{route.tab === 'routes' && !route.routeId && (
|
||||
<RoutesScreen
|
||||
api={api}
|
||||
|
||||
+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;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
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';
|
||||
@@ -7,7 +8,9 @@ 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,
|
||||
@@ -23,15 +26,28 @@ import {
|
||||
PageHeader,
|
||||
PrimaryCell,
|
||||
SearchableSelect,
|
||||
Select
|
||||
Select,
|
||||
TextInput
|
||||
} from '../ui';
|
||||
|
||||
export interface IRequestTypeAccessScreenProps {
|
||||
api: ApprovalRouteApi;
|
||||
dictionary?: PortalDictionaryClient;
|
||||
/** Фильтры из ссылки (aaacctype/aaaccemail) — перекрывают сохранённые, если заданы. */
|
||||
initialFilterRequestTypeId?: string;
|
||||
initialFilterEmail?: string;
|
||||
}
|
||||
|
||||
export const RequestTypeAccessScreen: React.FC<IRequestTypeAccessScreenProps> = ({ api, dictionary }) => {
|
||||
// Фильтры переживают перезагрузку — тот же приём, что и 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);
|
||||
@@ -41,10 +57,37 @@ export const RequestTypeAccessScreen: React.FC<IRequestTypeAccessScreenProps> =
|
||||
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().indexOf(filterEmail.trim().toLowerCase()) === -1) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
// Список SharePoint нужно вручную открыть на чтение этому email — иначе доступ
|
||||
// здесь настраивает только видимость в UI approval-route, но не в самом
|
||||
// SharePoint (поиск по порталу отфильтрует чужие заявки правами списка).
|
||||
@@ -87,6 +130,28 @@ export const RequestTypeAccessScreen: React.FC<IRequestTypeAccessScreenProps> =
|
||||
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}>
|
||||
<TextInput
|
||||
value={filterEmail}
|
||||
onChange={e => setFilterEmail(e.target.value)}
|
||||
placeholder={texts.requestTypeAccess.filterEmailPlaceholder}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{access.error && <Alert kind="err">{access.error}</Alert>}
|
||||
{deleteTarget.error && <Alert kind="err">{deleteTarget.error}</Alert>}
|
||||
|
||||
@@ -108,9 +173,15 @@ export const RequestTypeAccessScreen: React.FC<IRequestTypeAccessScreenProps> =
|
||||
)
|
||||
}
|
||||
]}
|
||||
rows={access.items}
|
||||
rows={filteredItems}
|
||||
keyOf={r => r.id}
|
||||
emptyMessage={access.isLoading ? texts.common.loading : texts.requestTypeAccess.emptyList}
|
||||
emptyMessage={
|
||||
access.isLoading
|
||||
? texts.common.loading
|
||||
: access.items.length > 0 && filteredItems.length === 0
|
||||
? texts.requestTypeAccess.emptyFiltered
|
||||
: texts.requestTypeAccess.emptyList
|
||||
}
|
||||
/>
|
||||
|
||||
{showForm && (
|
||||
|
||||
@@ -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. */
|
||||
@@ -85,6 +93,7 @@ const TABS: ScreenKey[] = ['dashboard', 'requestTypes', 'requestTypeAccess', 'ro
|
||||
|
||||
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);
|
||||
|
||||
@@ -185,6 +185,11 @@ export const texts = {
|
||||
title: 'Доступ к заявкам',
|
||||
add: 'Выдать доступ',
|
||||
emptyList: 'Пока никому не выдан доступ ни к одному типу заявок.',
|
||||
emptyFiltered: 'Ничего не найдено по заданным фильтрам.',
|
||||
filterRequestTypeLabel: 'Фильтр по типу заявки',
|
||||
filterRequestTypeAll: 'Все типы заявок',
|
||||
filterEmailLabel: 'Фильтр по сотруднику',
|
||||
filterEmailPlaceholder: 'Email сотрудника',
|
||||
columnRequestType: 'Тип заявки',
|
||||
columnEmail: 'Сотрудник',
|
||||
columnSharePointLists: 'Списки SharePoint (выдать доступ вручную)',
|
||||
|
||||
Reference in New Issue
Block a user