Управление заявками (#7)
Tests / frontend-test (push) Successful in 43s
Tests / backend-test (push) Successful in 2m36s

Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/7
This commit was merged in pull request #7.
This commit is contained in:
2026-08-07 09:49:38 +00:00
parent 9d9f390548
commit 0ffa032ade
42 changed files with 2436 additions and 65 deletions
@@ -0,0 +1,181 @@
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[];
}
export interface IManageRequestRouteParams {
requestTypeId: string;
requestId: string;
}
export interface IAdminRoute {
tab: ScreenKey;
/** Id маршрута — только при tab='routes': сразу открывает шаги ЭТОГО маршрута,
* минуя список (см. AdminApp — по нему домаршрут дочитывается через api.templateRoutes.get). */
routeId?: number;
/** Задан только при tab='resolve' и непустом requestTypeId. */
resolve?: IResolveRouteParams;
/** Задан только при tab='manageRequests' и непустых requestTypeId+requestId. */
manage?: IManageRequestRouteParams;
}
const ROUTE_CHANGE_EVENT = 'aa-route-change';
// 'routeSteps' сюда намеренно не входит — это не отдельная вкладка URL, а
// состояние вкладки 'routes' при наличии aaroute (см. parseRoute).
const TABS: ScreenKey[] = ['dashboard', 'requestTypes', 'routes', 'resolve', 'manageRequests'];
const PARAM_KEYS = [
'aatab', 'aaroute',
'aartype', 'aauser', 'aaproject', 'aaprojectname', 'aastatus', 'aafields',
'aamtype', 'aamreq'
];
// 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'))
}
: undefined;
const manageTypeId = params.get('aamtype');
const manageReqId = params.get('aamreq');
const manage: IManageRequestRouteParams | undefined =
tab === 'manageRequests' && manageTypeId && manageReqId
? { requestTypeId: manageTypeId, requestId: manageReqId }
: undefined;
return {
tab,
routeId: routeId && !isNaN(routeId) ? routeId : undefined,
resolve,
manage
};
}
// Собрать 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' && route.routeId) {
params.set('aaroute', String(route.routeId));
}
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.tab === 'manageRequests' && route.manage && route.manage.requestTypeId && route.manage.requestId) {
params.set('aamtype', route.manage.requestTypeId);
params.set('aamreq', route.manage.requestId);
}
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;
}