diff --git a/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx b/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx index ec86c28..691b78b 100644 --- a/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx +++ b/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx @@ -7,6 +7,7 @@ import { PortalDictionaryClient } from '../../services/portalDictionary'; import { SharePointListsClient } from '../../services/sharePointLists'; import { RequestManageService } from '../../services/requestManageService'; import { RequestType, TemplateRoute } from '../../models'; +import { navigate, useRoute } from '../../routing'; import { DashboardScreen } from '../screens/DashboardScreen'; import { RequestTypesScreen } from '../screens/RequestTypesScreen'; import { RoutesScreen } from '../screens/RoutesScreen'; @@ -23,76 +24,84 @@ export interface IAdminAppProps { currentUserEmail?: string; } -const ACTIVE_TAB_STORAGE_KEY = 'approvalAdmin.activeTab'; - -// 'routeSteps' сознательно НЕ входит: этот экран требует selectedRoute — полный -// объект маршрута, полученный по клику из RoutesScreen, а не просто ключ. После -// перезагрузки страницы такого объекта нет и взять его неоткуда, поэтому эту -// вкладку как «последнюю открытую» восстанавливать нельзя — если она вдруг -// окажется в localStorage (старое значение, ручная правка), откатываемся на дашборд. -const RESTORABLE_SCREENS: ScreenKey[] = ['dashboard', 'requestTypes', 'routes', 'resolve', 'manageRequests']; - -function readStoredScreen(): ScreenKey { - try { - const raw = window.localStorage.getItem(ACTIVE_TAB_STORAGE_KEY); - if (raw && (RESTORABLE_SCREENS as string[]).indexOf(raw) !== -1) return raw as ScreenKey; - } catch { - // localStorage недоступен (приватный режим и т.п.) — просто не восстанавливаем. - } - return 'dashboard'; -} - export const AdminApp: React.FC = ({ api, dictionary, sharePoint, requestManage, currentUserEmail }) => { - const [screen, setScreenState] = React.useState(readStoredScreen); + const route = useRoute(); const [selectedRoute, setSelectedRoute] = React.useState(); const [selectedRequestType, setSelectedRequestType] = React.useState(); - // 'routeSteps' в localStorage не пишем — см. RESTORABLE_SCREENS. - const setScreen = (key: ScreenKey): void => { - setScreenState(key); - if (key === 'routeSteps') return; - try { - window.localStorage.setItem(ACTIVE_TAB_STORAGE_KEY, key); - } catch { - // не критично — просто не запомнится - } - }; + const navigateTab = (key: ScreenKey): void => navigate({ tab: key }); - const navigate = (key: ScreenKey): void => { - setScreen(key); - }; - - const openSteps = (route: TemplateRoute): void => { - setSelectedRoute(route); + const openSteps = (r: TemplateRoute): void => { + setSelectedRoute(r); setSelectedRequestType(undefined); - api.requestTypes.get(route.requestTypeId) + api.requestTypes.get(r.requestTypeId) .then(setSelectedRequestType) .catch(() => setSelectedRequestType(undefined)); - setScreen('routeSteps'); + navigate({ tab: 'routes', routeId: r.id }); }; + // Открытие по прямой ссылке / обновление страницы / кнопка «назад»: в URL + // лежит только aaroute (число), а RouteStepsScreen ждёт полный TemplateRoute — + // дочитываем его через API. Если сюда пришли через openSteps (клик в + // RoutesScreen), selectedRoute уже тот самый — повторный запрос не нужен. + React.useEffect(() => { + if (route.tab !== 'routes' || !route.routeId) { + setSelectedRoute(undefined); + setSelectedRequestType(undefined); + return; + } + if (selectedRoute && selectedRoute.id === route.routeId) return; + + let alive = true; + api.templateRoutes.get(route.routeId) + .then(r => { + if (!alive) return; + setSelectedRoute(r); + return api.requestTypes.get(r.requestTypeId).then(rt => { + if (alive) setSelectedRequestType(rt); + }); + }) + .catch(() => { + if (alive) setSelectedRoute(undefined); + }); + return () => { + alive = false; + }; + // selectedRoute — намеренно не в зависимостях: иначе повторный fetch + // запускался бы сразу после того, как этот же эффект его и выставил. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [api, route.tab, route.routeId]); + return (
- +
- {screen === 'dashboard' && } - {screen === 'requestTypes' && } - {screen === 'routes' && } - {screen === 'routeSteps' && selectedRoute && ( + {route.tab === 'dashboard' && } + {route.tab === 'requestTypes' && } + {route.tab === 'routes' && !route.routeId && ( + + )} + {route.tab === 'routes' && route.routeId && selectedRoute && ( setScreen('routes')} + onBack={() => navigate({ tab: 'routes' })} /> )} - {screen === 'resolve' && ( - + {route.tab === 'resolve' && ( + )} - {screen === 'manageRequests' && ( - + {route.tab === 'manageRequests' && ( + )}
diff --git a/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsPhaseTable.tsx b/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsPhaseTable.tsx index daad771..4ae328c 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsPhaseTable.tsx +++ b/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsPhaseTable.tsx @@ -152,7 +152,7 @@ export const ManageRequestsPhaseTable: React.FC const isGhost = r.id < 0; return ( - block.start ? styles.parallelRow : undefined}> + {isBlockStart && ( {r.parallelWithPrev || block.end > block.start ? ( @@ -202,7 +202,7 @@ export const ManageRequestsPhaseTable: React.FC toggleParallel(i)} diff --git a/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsScreen.module.scss b/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsScreen.module.scss index 153dfa3..5883a6a 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsScreen.module.scss +++ b/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsScreen.module.scss @@ -45,12 +45,6 @@ font-weight: 600; } -// Строки одного параллельного блока — лёгкая заливка, чтобы объединённая -// ячейка «Шаг» читалась как один блок, а не как случайно схлопнутые строки. -.parallelRow > td { - background: rgba($gold-500, .06); -} - .flipY { svg { transform: scaleY(-1); } } @@ -66,6 +60,21 @@ background: #fff !important; } +// Включённое состояние кнопки «объединить с предыдущим шагом» — в отличие от +// остальных кнопок это переключатель состояния, а не разовое действие, +// поэтому оно залито (как в request-portal/shared/approval/ui/approval.module.scss/.iconBtnActive). +// !important по той же причине, что и у .iconBtnVisible. +.iconBtnActive { + border-color: $gold-500 !important; + background: $gold-500 !important; + color: $navy-950 !important; + + &:hover:not(:disabled) { + border-color: $gold-600 !important; + background: $gold-600 !important; + } +} + // Плашка-пикер под таблицей для добавления шага (вместо кнопки+модалки) — // повторяет request-portal/shared/approval/ui/ApprovalMatrix.tsx/.matrixPicker. .addRow { diff --git a/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsScreen.tsx b/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsScreen.tsx index 10a2a88..dc21171 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsScreen.tsx +++ b/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsScreen.tsx @@ -19,6 +19,7 @@ import { usePortalEmployees } from '../../hooks/usePortalEmployees'; import { useLocalStorageState } from '../../hooks/useLocalStorageState'; import { findEmployeeByEmail } from '../../utils/employee'; import { markParallel, withOrdersFrom } from '../../utils/orderBlocks'; +import { IManageRequestRouteParams, replaceRoute } from '../../routing'; import { Alert, AsyncSearchableSelect, @@ -39,6 +40,9 @@ export interface IManageRequestsScreenProps { /** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */ dictionary?: PortalDictionaryClient; requestManage: RequestManageService; + /** Заполнение из URL (глубокая ссылка на конкретную заявку) — есть, только + * если в адресе заданы aamtype/aamreq (см. routing.ts). */ + route?: IManageRequestRouteParams; } // Цвет плашки статуса — те же группы смысла, что и в request-portal @@ -97,12 +101,39 @@ const SELECTION_STORAGE_KEY = 'approvalAdmin.manageRequestsSelection'; // решение через request-portal), а вкладка могла быть открыта давно. const AUTO_REFRESH_MS = 2 * 60 * 1000; -export const ManageRequestsScreen: React.FC = ({ api, dictionary, requestManage }) => { +export const ManageRequestsScreen: React.FC = ({ api, dictionary, requestManage, route }) => { const requestTypes = useRequestTypes(api); const employees = usePortalEmployees(dictionary); const [selection, setSelection] = useLocalStorageState(SELECTION_STORAGE_KEY, EMPTY_SELECTION); const { requestTypeId, selectedRequest } = selection; + + // Открыли по глубокой ссылке (aamtype/aamreq в URL) — перекрывает то, что + // было в localStorage. Только на монтировании — см. тот же приём в + // ResolveScreen (route меняется при каждом собственном replaceRoute ниже). + // title/status заявки в URL не хранятся (их полностью определяет requestId) — + // ставим пустыми, реальные подтянутся, как только матрица загрузится (см. + // reloadMatrix ниже: там selection.selectedRequest патчится из matrix.request). + React.useEffect(() => { + if (!route) return; + setSelection({ + requestTypeId: route.requestTypeId, + selectedRequest: { id: Number(route.requestId), title: '', status: '', created: '', authorName: '' } + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Держим URL синхронным с выбором — открытую заявку можно скопировать и + // прислать коллеге. replaceRoute, а не navigate: смена заявки — это правка + // состояния текущего экрана, а не переход на новую страницу с отдельным + // шагом в истории браузера. title/status заявки в URL не пишем — их выводит + // сам requestId, хранить производные данные в ссылке незачем. + React.useEffect(() => { + replaceRoute({ + tab: 'manageRequests', + manage: selectedRequest ? { requestTypeId, requestId: String(selectedRequest.id) } : undefined + }); + }, [requestTypeId, selectedRequest]); const [matrix, setMatrix] = React.useState(); const [matrixLoading, setMatrixLoading] = React.useState(false); const [matrixError, setMatrixError] = React.useState(); @@ -156,7 +187,17 @@ export const ManageRequestsScreen: React.FC = ({ api setMatrixError(undefined); requestManage .getMatrix(requestType, selectedRequest.id) - .then(setMatrix) + .then(m => { + setMatrix(m); + // title/status в комбобоксе (а значит, и в localStorage) держим + // синхронными с сервером — особенно важно после захода по глубокой + // ссылке, где изначально они пустые (см. эффект-затравку выше). + setSelection(prev => + prev.selectedRequest && prev.selectedRequest.id === m.request.id + ? { ...prev, selectedRequest: m.request } + : prev + ); + }) .catch((e: Error) => setMatrixError(e.message)) .finally(() => { if (!silent) setMatrixLoading(false); diff --git a/frontend/src/webparts/approvalAdmin/components/screens/ResolveScreen.tsx b/frontend/src/webparts/approvalAdmin/components/screens/ResolveScreen.tsx index 0acd104..25b342f 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/ResolveScreen.tsx +++ b/frontend/src/webparts/approvalAdmin/components/screens/ResolveScreen.tsx @@ -11,6 +11,7 @@ import { usePortalEmployees } from '../../hooks/usePortalEmployees'; import { useSharePointFields } from '../../hooks/useSharePointFields'; import { useLocalStorageState } from '../../hooks/useLocalStorageState'; import { findEmployeeByEmail, toEmployeeOption } from '../../utils/employee'; +import { IResolveRouteParams, replaceRoute } from '../../routing'; import { Alert, AsyncSearchableSelect, @@ -35,6 +36,9 @@ export interface IResolveScreenProps { dictionary?: PortalDictionaryClient; sharePoint: SharePointListsClient; currentUserEmail?: string; + /** Заполнение из URL (глубокая ссылка) — есть, только если в адресе заданы + * параметры проверки маршрута (aartype и т.д., см. routing.ts). */ + route?: IResolveRouteParams; } const STATUS_OPTIONS = [RouteStepStatus.Approval, RouteStepStatus.Execution] @@ -63,11 +67,36 @@ const EMPTY_RESOLVE_FORM: IResolveFormState = { fieldValues: [] }; -export const ResolveScreen: React.FC = ({ api, dictionary, sharePoint, currentUserEmail }) => { +export const ResolveScreen: React.FC = ({ api, dictionary, sharePoint, currentUserEmail, route }) => { const requestTypes = useRequestTypes(api); const [form, setForm] = useLocalStorageState(RESOLVE_FORM_STORAGE_KEY, EMPTY_RESOLVE_FORM); const { requestTypeId, userEmail, projectId, projectLabel, status, fieldValues = [] } = form; + // Открыли по глубокой ссылке (aartype и т.д. в URL) — параметры оттуда + // перекрывают то, что было в localStorage. Только на монтировании: route + // меняется при каждом собственном же replaceRoute ниже, а не только при + // переходе по внешней ссылке — сравнивать его на равенство и держать в + // зависимостях смысла нет. + React.useEffect(() => { + if (!route) return; + setForm({ + requestTypeId: route.requestTypeId, + userEmail: route.userEmail, + projectId: route.projectId, + projectLabel: route.projectLabel, + status: route.status, + fieldValues: route.fieldValues + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Держим URL синхронным с формой — правки сразу становятся частью ссылки, + // которую можно скопировать/сохранить. replaceRoute, а не navigate: иначе + // каждый введённый символ добавлял бы шаг в историю браузера. + React.useEffect(() => { + replaceRoute({ tab: 'resolve', resolve: { requestTypeId, userEmail, projectId, projectLabel, status, fieldValues } }); + }, [requestTypeId, userEmail, projectId, projectLabel, status, fieldValues]); + const requestType = requestTypes.items.find(rt => rt.id === Number(requestTypeId)); const hasListId = Boolean(requestType?.listId); const sharePointFields = useSharePointFields(sharePoint, requestType?.siteUrl, requestType?.listId); diff --git a/frontend/src/webparts/approvalAdmin/routing.ts b/frontend/src/webparts/approvalAdmin/routing.ts new file mode 100644 index 0000000..7cc4b40 --- /dev/null +++ b/frontend/src/webparts/approvalAdmin/routing.ts @@ -0,0 +1,178 @@ +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' +]; + +function parseTab(raw: string | null): ScreenKey { + return raw && (TABS as string[]).indexOf(raw) !== -1 ? (raw as ScreenKey) : 'dashboard'; +} + +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')) + } + : 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(() => 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; +}