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 => 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(() => 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; }