Добавить фильтры по типу заявки и сотруднику в «Доступ к заявкам»

Персистятся тем же приёмом, что и routesFilter у маршрутов: живут в
адресе (aaacctype/aaaccemail) и в localStorage, переживают перезагрузку
и возврат на вкладку.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
gorbenko.pavel
2026-09-25 16:42:27 +05:00
parent f8f9d20d14
commit b128637c64
5 changed files with 116 additions and 5 deletions
@@ -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}
@@ -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 (выдать доступ вручную)',