6a01101054
Выбранные типы заявок выгружаются в xlsx (лист на тип: №, роль, исполнитель, условие) через POST /api/reports/routes. Пункт меню стоит после «Проверки маршрута», «Доступ к заявкам» — последним. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
249 lines
13 KiB
TypeScript
249 lines
13 KiB
TypeScript
import * as React from 'react';
|
||
import { ScreenKey } from './components/app/types';
|
||
import { ResolveFieldValue, RouteStepStatus } from './models';
|
||
|
||
// Маршрутизация веб-части по параметрам URL: у каждой верхней вкладки свой
|
||
// адрес, а «Проверка маршрута» и «Редактирование заявок» — ещё и глубокие
|
||
// ссылки на конкретный подбор (маршрут+сотрудник / конкретная заявка), которые
|
||
// можно передать коллеге или обновить страницу без потери контекста.
|
||
//
|
||
// Префикс aa (approval admin) — тот же приём, что в request-portal
|
||
// (src/webparts/requestPortal/shared/routing.ts, префикс rp): SharePoint
|
||
// резервирует часть имён параметров (id/mode/type) под свою навигацию, поэтому
|
||
// используем собственные, заведомо не пересекающиеся имена.
|
||
//
|
||
// Навигация — history.pushState без перезагрузки страницы (SPA); useRoute()
|
||
// подписан и на popstate (кнопка «назад»), и на собственное событие (pushState
|
||
// само popstate не порождает).
|
||
|
||
export interface IResolveRouteParams {
|
||
requestTypeId: string;
|
||
userEmail: string;
|
||
projectId: string;
|
||
/** Название проекта — чтобы показать выбор до отдельного похода в справочник. */
|
||
projectLabel: string;
|
||
status: RouteStepStatus;
|
||
fieldValues: ResolveFieldValue[];
|
||
/**
|
||
* Для шагов с HeadType.DepartmentHead (см. ResolveTemplateRouteQuery.departmentId).
|
||
* Без отдельного departmentLabel — в отличие от projectLabel (AsyncSearchableSelect,
|
||
* пагинированный поиск), отделы выбираются из уже целиком загруженного справочника
|
||
* (SearchableSelect), так что подпись резолвится на месте по id, а не хранится отдельно.
|
||
*/
|
||
departmentId: string;
|
||
}
|
||
|
||
export interface IManageByUserRouteParams {
|
||
/** Email выбранного сотрудника — он однозначно определяет весь экран (обе панели). */
|
||
userEmail: string;
|
||
/**
|
||
* На кого заменять. Часть ссылки, а не только состояние экрана: замена обычно
|
||
* идёт в несколько приёмов (сначала заявки, потом маршруты, потом что-то
|
||
* отдельной строкой), и выбранного «получателя» незачем терять между ними.
|
||
* Пусто — получатель ещё не выбран.
|
||
*/
|
||
targetEmail: string;
|
||
}
|
||
|
||
export interface IManageRequestRouteParams {
|
||
requestTypeId: string;
|
||
requestId: string;
|
||
/**
|
||
* Партиция (список-архив данных), где физически лежит заявка — у разных
|
||
* партиций независимая нумерация Id (см. ТЗ по архивации), поэтому без
|
||
* listId requestId неоднозначен. Пусто — старая ссылка (до архивации) или
|
||
* ссылка ещё не резолвнута; тогда экран считает, что заявка в текущей
|
||
* партиции (см. ManageRequestsScreen).
|
||
*/
|
||
listId: string;
|
||
}
|
||
|
||
export interface IAdminRoute {
|
||
tab: ScreenKey;
|
||
/** Id маршрута — только при tab='routes': сразу открывает шаги ЭТОГО маршрута,
|
||
* минуя список (см. AdminApp — по нему домаршрут дочитывается через api.templateRoutes.get). */
|
||
routeId?: number;
|
||
/**
|
||
* Фильтр списка маршрутов по типу заявки (id как строка, '' — все типы).
|
||
* Живёт и при открытых шагах (routeId): возврат «к маршрутам» должен вернуть
|
||
* к тому же отфильтрованному списку, а не сбросить фильтр.
|
||
*/
|
||
routesFilter?: string;
|
||
/**
|
||
* Фильтры таблицы «Доступ к заявкам» (id типа как строка, email) — только при
|
||
* tab='requestTypeAccess'. Та же логика, что и у routesFilter: живут в адресе,
|
||
* чтобы отфильтрованную ссылку можно было передать и чтобы фильтр не терялся
|
||
* при возврате на вкладку.
|
||
*/
|
||
accessFilterRequestTypeId?: string;
|
||
accessFilterEmail?: string;
|
||
/** Задан только при tab='resolve' и непустом requestTypeId. */
|
||
resolve?: IResolveRouteParams;
|
||
/** Задан только при tab='manageRequests' и непустых requestTypeId+requestId. */
|
||
manage?: IManageRequestRouteParams;
|
||
/** Задан только при tab='manageByUser' и непустом userEmail. */
|
||
manageByUser?: IManageByUserRouteParams;
|
||
}
|
||
|
||
const ROUTE_CHANGE_EVENT = 'aa-route-change';
|
||
|
||
// 'routeSteps' сюда намеренно не входит — это не отдельная вкладка URL, а
|
||
// состояние вкладки 'routes' при наличии aaroute (см. parseRoute).
|
||
const TABS: ScreenKey[] = ['dashboard', 'requestTypes', 'routes', 'resolve', 'routesExport', 'manageRequests', 'manageByUser', 'requestTypeAccess'];
|
||
|
||
const PARAM_KEYS = [
|
||
'aatab', 'aaroute', 'aarfilter',
|
||
'aaacctype', 'aaaccemail',
|
||
'aartype', 'aauser', 'aaproject', 'aaprojectname', 'aastatus', 'aafields', 'aadept',
|
||
'aamtype', 'aamreq', 'aamlist',
|
||
'aabuser', 'aabtarget'
|
||
];
|
||
|
||
// raw — результат URLSearchParams.get() (DOM API, возвращает string | null).
|
||
// eslint-disable-next-line @rushstack/no-new-null
|
||
function parseTab(raw: string | null): ScreenKey {
|
||
return raw && (TABS as string[]).indexOf(raw) !== -1 ? (raw as ScreenKey) : 'dashboard';
|
||
}
|
||
|
||
// eslint-disable-next-line @rushstack/no-new-null -- raw — результат URLSearchParams.get() (DOM API)
|
||
function parseFieldValues(raw: string | null): ResolveFieldValue[] {
|
||
if (!raw) return [];
|
||
try {
|
||
const parsed: unknown = JSON.parse(raw);
|
||
if (!Array.isArray(parsed)) return [];
|
||
return parsed
|
||
.filter((x): x is Record<string, unknown> => typeof x === 'object' && x !== null)
|
||
.map(x => ({ fieldName: String(x.fieldName || ''), value: String(x.value || '') }));
|
||
} catch {
|
||
// Битый/устаревший JSON в ссылке — не роняем экран, просто без предзаполненных условий.
|
||
return [];
|
||
}
|
||
}
|
||
|
||
/** Разобрать строку запроса (по умолчанию — из адреса страницы) в маршрут. */
|
||
export function parseRoute(search: string = window.location.search): IAdminRoute {
|
||
const params = new URLSearchParams(search);
|
||
const tab = parseTab(params.get('aatab'));
|
||
|
||
const routeIdRaw = tab === 'routes' ? params.get('aaroute') : null;
|
||
const routeId = routeIdRaw ? Number(routeIdRaw) : undefined;
|
||
|
||
const resolveTypeId = params.get('aartype');
|
||
const resolve: IResolveRouteParams | undefined =
|
||
tab === 'resolve' && resolveTypeId
|
||
? {
|
||
requestTypeId: resolveTypeId,
|
||
userEmail: params.get('aauser') || '',
|
||
projectId: params.get('aaproject') || '',
|
||
projectLabel: params.get('aaprojectname') || '',
|
||
status: (params.get('aastatus') as RouteStepStatus) || RouteStepStatus.Approval,
|
||
fieldValues: parseFieldValues(params.get('aafields')),
|
||
departmentId: params.get('aadept') || ''
|
||
}
|
||
: undefined;
|
||
|
||
const manageTypeId = params.get('aamtype');
|
||
const manageReqId = params.get('aamreq');
|
||
const manage: IManageRequestRouteParams | undefined =
|
||
tab === 'manageRequests' && manageTypeId && manageReqId
|
||
? { requestTypeId: manageTypeId, requestId: manageReqId, listId: params.get('aamlist') || '' }
|
||
: undefined;
|
||
|
||
const byUserEmail = tab === 'manageByUser' ? params.get('aabuser') : null;
|
||
|
||
return {
|
||
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
|
||
? { userEmail: byUserEmail, targetEmail: params.get('aabtarget') || '' }
|
||
: undefined
|
||
};
|
||
}
|
||
|
||
// Собрать query-строку маршрута, сохранив посторонние параметры страницы
|
||
// (SharePoint/Workbench могут держать в URL свои — их трогать нельзя).
|
||
function buildQuery(route: IAdminRoute): string {
|
||
const params = new URLSearchParams(window.location.search);
|
||
PARAM_KEYS.forEach(k => params.delete(k));
|
||
|
||
if (route.tab !== 'dashboard') params.set('aatab', route.tab);
|
||
|
||
if (route.tab === 'routes') {
|
||
if (route.routeId) params.set('aaroute', String(route.routeId));
|
||
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);
|
||
if (route.resolve.projectId) params.set('aaproject', route.resolve.projectId);
|
||
if (route.resolve.projectLabel) params.set('aaprojectname', route.resolve.projectLabel);
|
||
if (route.resolve.status) params.set('aastatus', route.resolve.status);
|
||
if (route.resolve.fieldValues.length > 0) params.set('aafields', JSON.stringify(route.resolve.fieldValues));
|
||
if (route.resolve.departmentId) params.set('aadept', route.resolve.departmentId);
|
||
}
|
||
|
||
if (route.tab === 'manageRequests' && route.manage && route.manage.requestTypeId && route.manage.requestId) {
|
||
params.set('aamtype', route.manage.requestTypeId);
|
||
params.set('aamreq', route.manage.requestId);
|
||
if (route.manage.listId) params.set('aamlist', route.manage.listId);
|
||
}
|
||
|
||
if (route.tab === 'manageByUser' && route.manageByUser?.userEmail) {
|
||
params.set('aabuser', route.manageByUser.userEmail);
|
||
if (route.manageByUser.targetEmail) params.set('aabtarget', route.manageByUser.targetEmail);
|
||
}
|
||
|
||
const query = params.toString();
|
||
return `${window.location.pathname}${query ? `?${query}` : ''}${window.location.hash}`;
|
||
}
|
||
|
||
/** Перейти на другой маршрут без перезагрузки страницы (SPA) — новая запись в истории. */
|
||
export function navigate(route: IAdminRoute): void {
|
||
window.history.pushState({}, '', buildQuery(route));
|
||
window.dispatchEvent(new Event(ROUTE_CHANGE_EVENT));
|
||
}
|
||
|
||
/**
|
||
* Обновить параметры ТЕКУЩЕГО маршрута без новой записи в истории — для форм
|
||
* (ResolveScreen/ManageRequestsScreen), которые синхронизируют URL на каждое
|
||
* изменение поля: pushState на каждый символ ввода превратил бы кнопку «назад»
|
||
* в полистывание истории набора текста вместо переключения вкладок.
|
||
*/
|
||
export function replaceRoute(route: IAdminRoute): void {
|
||
window.history.replaceState({}, '', buildQuery(route));
|
||
window.dispatchEvent(new Event(ROUTE_CHANGE_EVENT));
|
||
}
|
||
|
||
/** Ссылка на маршрут — для href (открыть в новой вкладке, скопировать, поделиться). */
|
||
export function routeUrl(route: IAdminRoute): string {
|
||
return buildQuery(route);
|
||
}
|
||
|
||
/** Текущий маршрут, реактивно обновляется по navigate() и кнопке «назад». */
|
||
export function useRoute(): IAdminRoute {
|
||
const [route, setRoute] = React.useState<IAdminRoute>(() => parseRoute());
|
||
|
||
React.useEffect(() => {
|
||
const onChange = (): void => setRoute(parseRoute());
|
||
window.addEventListener('popstate', onChange);
|
||
window.addEventListener(ROUTE_CHANGE_EVENT, onChange);
|
||
return () => {
|
||
window.removeEventListener('popstate', onChange);
|
||
window.removeEventListener(ROUTE_CHANGE_EVENT, onChange);
|
||
};
|
||
}, []);
|
||
|
||
return route;
|
||
}
|