import * as React from 'react'; // Маршрутизация портала по параметрам URL. // // Раздел бокового меню — rpnav: 'new' (подать заявку, по умолчанию), // 'approvals' (ждут моего решения), 'own' (мои заявки), 'search' (поиск // сразу по обеим категориям — и по своим заявкам, и по тем, где участвовал). // Открытая заявка — переход по ссылке из потока Power Automate или клик по // строке таблицы: // …/СтраницаПортала.aspx?rpmode=approve&rptype=<форма>&rpid= // где rptype = ключ формы в реестре (forms/registry), rpid = RowID заявки в // списке данных. Заявка показывается в выезжающей панели ПОВЕРХ текущего // раздела, поэтому rpnav сохраняется вместе с rpmode. // // Ссылка на ПУСТУЮ новую форму конкретного типа (без предзаполнения, минуя // плитки) — см. newRequestLinkUrl(): // …/СтраницаПортала.aspx?rpmode=create&rptype=<форма> // Отличается от «создать на основе» (тот же rpmode=create, но с rpsource) — // без rpsource форма просто открывается пустой. // // Префикс rp (request-portal): имена id/mode/type зарезервированы SharePoint // (напр. ?id= он трактует как навигацию к элементу → «Элемент не существует»), // поэтому используем собственные, неконфликтные имена параметров. // // Навигация внутри портала идёт через navigate() — это history.pushState без // перезагрузки страницы. useRoute() подписан и на popstate (кнопка «назад»), // и на её собственное событие, поэтому реагирует на оба способа смены URL. // 'edit' — открыть СВОЮ незавершённую заявку (черновик / возвращённую / // отклонённую) на редактирование в той же выезжающей панели, что и просмотр. export type PortalMode = 'submit' | 'approve' | 'create' | 'edit'; /** * Раздел бокового меню. * * Три раздела — очереди действий текущего пользователя, и они НЕ пересекаются * (см. ApprovalQueueKind в myRequests.service.ts): * 'approvals' — ждут его согласования (статус строки «На согласовании»), * 'execution' — ждут его исполнения (статус строки «На исполнении»), * 'rework' — ЕГО заявки, которые вернули ему на доработку (статус ЗАЯВКИ * «Возвращена на доработку»); в отличие от первых двух смотрит * на список данных по автору, а не на строки маршрута. */ export type NavSection = 'new' | 'approvals' | 'execution' | 'rework' | 'own' | 'search' | 'reports' | 'allRequests'; /** * Подвкладка раздела «Мои заявки». 'created' — заявки, где пользователь автор * (единственная, где есть фильтр по статусу); остальные — история его решений * как участника чужих маршрутов. */ export type OwnTab = 'created' | 'approved' | 'rejected' | 'executed'; export interface IRoute { mode: PortalMode; /** Раздел бокового меню; сохраняется и когда поверх открыта заявка. */ nav: NavSection; /** Ключ формы (тип заявки) — для 'approve', 'edit' и 'create'. */ formKey?: string; /** Id заявки в списке данных — для 'approve' и 'edit'. */ itemId?: number; /** * Серверный относительный url КОНКРЕТНОЙ партиции, где лежит заявка — для * 'approve' и 'edit'. Списки заявок нумерованные (см. shared/approval/ * listPartitions.ts): без этого поля заявка всегда открывалась бы так, будто * лежит в ТЕКУЩЕЙ партиции, и архивная заявка читалась/сохранялась бы не в * тот список. Не задан — значит партиция неизвестна (старая ссылка без * параметра), и форма/просмотр откатываются на текущую партицию. */ listId?: string; /** Id исходной заявки для предзаполнения — для 'create' (напр. орг. места на основе прием). */ sourceId?: number; /** * Id строки-источника во внешней системе (Dataverse «Строка заявки», GUID * первичного ключа). Приходит по ссылке из внешней системы вместе с rptype — * форма «СЗ на перемещение ТМЦ» по нему после отправки на согласование * записывает обратно ссылку на заявку (см. shared/dataverse/demandLine.service). */ positionId?: string; /** * Id номенклатуры для GET /Dictionary/Nomenclature/{id} — форма предзаполняет * табличную часть остатками по складам. Приходит по той же внешней ссылке. */ nomenclatureId?: string; /** * Склад-фильтр для GET /Dictionary/Nomenclature — вернуть остатки только по * этому складу. Приходит по той же внешней ссылке; пусто — все склады. */ storeName?: string; /** * Система, из которой открыли форму по внешней ссылке (параметр `source`, * напр. 'kps'). Сохраняется в заявке, чтобы знать, откуда она пришла и куда * отвечать. Не путать с sourceId (rpsource) — тот про предзаполнение из * другой заявки портала. */ sourceSystem?: string; /** Подвкладка «Моих заявок»; задана только при nav='own'. */ ownTab?: OwnTab; /** Фильтр по статусу заявки; задан только при nav='own' и ownTab='created'. */ ownStatus?: string; /** * Выбранный тип заявки в разделе «Все заявки» (nav='allRequests') — числовой * RequestTypeId approval-route. Не задан — раздел сам выберет тип (первый * доступный текущему пользователю, см. AllRequestsFeature). */ allTypeId?: number; /** Номер страницы таблицы (1-based). 1 — значение по умолчанию, в адрес не пишется. */ page?: number; /** Текст поиска по таблице. Пустой — значение по умолчанию, в адрес не пишется. */ query?: string; } const ROUTE_CHANGE_EVENT = 'rp-route-change'; // Подвкладка и фильтр «Моих заявок» переживают перезагрузку страницы и новый // визит (localStorage), но НЕ более того — как и текст поиска в // usePersistedQuery.ts, это просто «продолжить с того же места», а не // синхронизация между вкладками/устройствами. // // Читаются здесь же, в parseRoute, а не отдельным хуком в компоненте: URL уже // является единственным источником правды для ownTab/ownStatus (см. // buildQuery — он не пишет значения по умолчанию в адрес), поэтому именно тут, // где URL молчит, естественно спросить «а что было в прошлый раз». // Пишутся ТОЛЬКО через persistOwnTab/persistOwnStatus — то есть только там, // где пользователь СОЗНАТЕЛЬНО меняет вкладку или фильтр (см. // gotoOwnTab/setOwnStatus в RequestPortal.tsx), а не при каждой навигации: // иначе, например, просто открыв и закрыв заявку, можно было бы затереть // запомненное значение значением по умолчанию. // // НАМЕРЕННО два отдельных сеттера, а не один persistOwnSelection(tab, status): // переключение НА подвкладку с решениями (approved/rejected/executed) должно // запомнить только её саму, не трогая фильтр статуса — тот относится // ИСКЛЮЧИТЕЛЬНО к «Инициированным» и при возврате туда должен найтись таким // же, каким был оставлен. Один сеттер этого не даёт: при переключении на // другую подвкладку он получал бы ownStatus=undefined (потому что на других // подвкладках фильтра просто нет) и стирал бы запомненное значение — что и // произошло на практике: уйдя с «Инициированных» на «Согласованные» и // вернувшись обратно, фильтр каждый раз сбрасывался на «Все статусы». const OWN_TAB_STORAGE_KEY = 'rp-own-tab'; const OWN_STATUS_STORAGE_KEY = 'rp-own-status'; // Раздел бокового меню — тем же приёмом: запоминается только по явному клику // (см. persistNav в goto() RequestPortal.tsx), чтобы заход на портал без // rpnav в адресе открывал тот раздел, на котором остановились в прошлый раз, // а не всегда «Новую заявку». const NAV_STORAGE_KEY = 'rp-nav'; // Страница запоминается ОТДЕЛЬНО ДЛЯ КАЖДОЙ таблицы (см. viewKeyOf): один общий // номер переносился бы между разделами, где у каждого свой объём данных, — // уйдя со страницы 3 «Моих заявок» в «На согласовании», где страница всего // одна, пользователь попал бы в пустоту. const PAGE_STORAGE_PREFIX = 'rp-page:'; // Текст поиска — тоже свой на каждую таблицу. Префикс НЕ меняем: под ним // поисковые запросы уже лежат (раньше их хранил хук usePersistedQuery), и // сохранённое у пользователей должно пережить переезд в маршрут. const QUERY_STORAGE_PREFIX = 'rp-search-query:'; /** * Ключ таблицы для запоминания страницы. Совпадает по смыслу с ListView в * RequestsTableView (плюс 'search'): у каждой подвкладки «Моих заявок» свой * список и, значит, своя страница. */ function viewKeyOf(nav: NavSection, ownTab: OwnTab | undefined): string { return nav === 'own' ? `own:${ownTab ?? 'created'}` : nav; } function readStoredPage(viewKey: string): number | undefined { try { const raw = window.localStorage.getItem(PAGE_STORAGE_PREFIX + viewKey); if (!raw) return undefined; const page = Number(raw); return Number.isInteger(page) && page > 1 ? page : undefined; } catch { return undefined; } } /** * Запомненная страница таблицы, на которую собираемся перейти. Нужна там, где * маршрут СТРОИТСЯ (переход по меню/подвкладке): parseRoute подставил бы её и * сам, но тогда номер остался бы только в состоянии и не попал бы в адрес — * ссылка на список показывала бы не ту страницу, что открыта на экране. * * ownTab не задан (переход в «Мои заявки» из другого раздела) — берём * запомненную подвкладку: именно она и откроется (см. parseOwnTab). */ export function storedPageFor(nav: NavSection, ownTab?: OwnTab): number { const tab = nav === 'own' ? ownTab ?? readStoredOwnTab() : undefined; return readStoredPage(viewKeyOf(nav, tab)) ?? 1; } function readStoredQuery(viewKey: string): string { try { return window.localStorage.getItem(QUERY_STORAGE_PREFIX + viewKey) || ''; } catch { return ''; } } /** Запомненный текст поиска таблицы — по тем же причинам, что и storedPageFor. */ export function storedQueryFor(nav: NavSection, ownTab?: OwnTab): string { const tab = nav === 'own' ? ownTab ?? readStoredOwnTab() : undefined; return readStoredQuery(viewKeyOf(nav, tab)); } /** Запомнить текст поиска текущей таблицы. */ export function persistQuery(route: IRoute, query: string): void { const viewKey = viewKeyOf(route.nav, route.ownTab); try { if (query) { window.localStorage.setItem(QUERY_STORAGE_PREFIX + viewKey, query); } else { window.localStorage.removeItem(QUERY_STORAGE_PREFIX + viewKey); } } catch { // localStorage недоступен (приватный режим и т.п.) — просто не сохраняем. } } /** * Запомнить страницу текущей таблицы. Ключ выводится из самого маршрута — * вызывающей стороне не нужно знать, как таблицы различаются между собой. */ export function persistPage(route: IRoute, page: number): void { const viewKey = viewKeyOf(route.nav, route.ownTab); try { // Первая страница — значение по умолчанию: храним её ОТСУТСТВИЕМ ключа, // иначе в localStorage копился бы мусор из единиц по каждой таблице. if (page > 1) { window.localStorage.setItem(PAGE_STORAGE_PREFIX + viewKey, String(page)); } else { window.localStorage.removeItem(PAGE_STORAGE_PREFIX + viewKey); } } catch { // localStorage недоступен (приватный режим и т.п.) — просто не сохраняем. } } function readStoredOwnTab(): OwnTab { try { const raw = window.localStorage.getItem(OWN_TAB_STORAGE_KEY); return raw === 'approved' || raw === 'rejected' || raw === 'executed' ? raw : 'created'; } catch { return 'created'; } } function readStoredOwnStatus(): string | undefined { try { return window.localStorage.getItem(OWN_STATUS_STORAGE_KEY) || undefined; } catch { return undefined; } } /** * Запомненные подвкладка и фильтр — как и storedPageFor, нужны там, где маршрут * СТРОИТСЯ: parseRoute подставил бы их и сам, но тогда они остались бы только в * состоянии и не попали бы в адрес (ссылка на раздел вела бы не туда, что * открыто на экране). */ export function storedOwnTab(): OwnTab { return readStoredOwnTab(); } /** Вне вкладки «Инициированные» фильтра по статусу нет по смыслу — там отбор задаёт сама вкладка. */ export function storedOwnStatus(ownTab: OwnTab): string | undefined { return ownTab === 'created' ? readStoredOwnStatus() : undefined; } /** Запомнить подвкладку «Моих заявок» — вызывать только при явном выборе пользователя. */ export function persistOwnTab(ownTab: OwnTab): void { try { window.localStorage.setItem(OWN_TAB_STORAGE_KEY, ownTab); } catch { // localStorage недоступен (приватный режим и т.п.) — просто не сохраняем. } } /** Запомнить фильтр по статусу вкладки «Инициированные» — вызывать только при явном выборе пользователя. */ export function persistOwnStatus(ownStatus: string | undefined): void { try { if (ownStatus) { window.localStorage.setItem(OWN_STATUS_STORAGE_KEY, ownStatus); } else { window.localStorage.removeItem(OWN_STATUS_STORAGE_KEY); } } catch { // localStorage недоступен (приватный режим и т.п.) — просто не сохраняем. } } function readStoredNav(): NavSection { try { const raw = window.localStorage.getItem(NAV_STORAGE_KEY); return raw === 'approvals' || raw === 'execution' || raw === 'rework' || raw === 'own' || raw === 'search' || raw === 'reports' || raw === 'allRequests' || raw === 'new' ? raw : 'new'; } catch { return 'new'; } } // Выбранный тип заявки в разделе «Все заявки» — тем же приёмом, что и // подвкладка/фильтр «Моих заявок» (OWN_TAB_STORAGE_KEY выше): переживает // перезагрузку страницы и новый визит, но не более того. Без этого переход в // другой раздел и обратно (клик по пункту меню, без rpalltype в адресе) // каждый раз сбрасывал бы выбор на первый доступный тип. const ALL_TYPE_STORAGE_KEY = 'rp-all-type'; function readStoredAllTypeId(): number | undefined { try { const raw = window.localStorage.getItem(ALL_TYPE_STORAGE_KEY); const id = Number(raw); return raw && Number.isInteger(id) && id > 0 ? id : undefined; } catch { return undefined; } } /** Запомненный тип «Все заявки» — как и storedOwnTab, нужен там, где маршрут СТРОИТСЯ. */ export function storedAllTypeId(): number | undefined { return readStoredAllTypeId(); } /** Запомнить тип «Все заявки» — вызывать только при явном выборе пользователя. */ export function persistAllTypeId(typeId: number): void { try { window.localStorage.setItem(ALL_TYPE_STORAGE_KEY, String(typeId)); } catch { // localStorage недоступен (приватный режим и т.п.) — просто не сохраняем. } } /** Запомнить раздел бокового меню — вызывать только при явном выборе пользователя. */ export function persistNav(nav: NavSection): void { try { window.localStorage.setItem(NAV_STORAGE_KEY, nav); } catch { // localStorage недоступен (приватный режим и т.п.) — просто не сохраняем. } } function parseNav(raw: string | undefined): NavSection { if ( raw === 'approvals' || raw === 'execution' || raw === 'rework' || raw === 'own' || raw === 'search' || raw === 'reports' || raw === 'allRequests' || raw === 'new' ) { return raw; } // В адресе раздела нет (rpnav отсутствует, в т.ч. при заходе на портал по // голой ссылке) — открываем запомненный раздел, а не всегда «Новую заявку». return readStoredNav(); } function parseOwnTab(raw: string | undefined): OwnTab { if (raw === 'approved' || raw === 'rejected' || raw === 'executed' || raw === 'created') return raw; return readStoredOwnTab(); } /** Разобрать строку запроса (по умолчанию — из адреса страницы) в маршрут. */ export function parseRoute(search: string = window.location.search): IRoute { const params = new URLSearchParams(search); const mode = params.get('rpmode'); const nav = parseNav(params.get('rpnav') ?? undefined); const formKey = params.get('rptype') ?? undefined; const idRaw = params.get('rpid'); const itemId = idRaw ? Number(idRaw) : undefined; const listId = params.get('rplist') ?? undefined; const sourceRaw = params.get('rpsource'); const sourceId = sourceRaw ? Number(sourceRaw) : undefined; // Параметры внешней ссылки (без rp-префикса — так их формирует внешняя // система, менять её мы не можем). Актуальны только для сценария «создать» по // rptype: заполнить форму ТМЦ из справочника и запомнить строку-источник. const positionId = params.get('positionId') ?? undefined; const nomenclatureId = params.get('nomenclatureId') ?? undefined; const storeName = params.get('storeName') ?? undefined; const sourceSystem = params.get('source') ?? undefined; // Подвкладка и фильтр — состояние раздела «Мои заявки», поэтому вне его не // читаются вовсе (иначе чужой rptab из ссылки «протёк» бы в другой раздел). // Фильтр по статусу живёт только на вкладке «Инициированные»: у вкладок с // решениями отбор задаёт сама вкладка. const ownTab = nav === 'own' ? parseOwnTab(params.get('rptab') ?? undefined) : undefined; const ownStatus = ownTab === 'created' ? params.get('rpstatus') ?? readStoredOwnStatus() : undefined; // Тип заявки в разделе «Все заявки» — из адреса, иначе запомненный (та же // логика, что у ownTab/ownStatus выше). Если ни того, ни другого нет (первый // визит) — undefined, и раздел сам выберет первый доступный тип (см. // AllRequestsFeature), а как только он выберет — маршрут и localStorage // подхватят выбор (см. gotoAllRequestsType в RequestPortal.tsx). const allTypeIdParam = nav === 'allRequests' ? params.get('rpalltype') : null; const allTypeIdFromUrl = allTypeIdParam ? Number(allTypeIdParam) : NaN; const allTypeId = nav === 'allRequests' ? (Number.isInteger(allTypeIdFromUrl) && allTypeIdFromUrl > 0 ? allTypeIdFromUrl : readStoredAllTypeId()) : undefined; // Страница: из адреса, иначе — запомненная для ЭТОЙ таблицы. Мусор в // параметре (0, минус, буквы) трактуем как первую страницу, а не как ошибку: // ссылка приходит извне, ронять из-за неё раздел незачем. const pageRaw = Number(params.get('rppage')); const page = Number.isInteger(pageRaw) && pageRaw > 1 ? pageRaw : readStoredPage(viewKeyOf(nav, ownTab)) ?? 1; // Текст поиска — из адреса, иначе запомненный для этой же таблицы. const query = params.get('rpq') ?? readStoredQuery(viewKeyOf(nav, ownTab)); // Открытая заявка (панель поверх раздела) — при полном наборе параметров. // Раздел под панелью сохраняется целиком, вместе с подвкладкой, фильтром и // страницей: закрыв панель, пользователь должен вернуться ровно в тот же // список и на то же место в нём. if (mode === 'approve' && formKey && itemId && !isNaN(itemId)) { return { mode: 'approve', nav, formKey, itemId, listId, ownTab, ownStatus, allTypeId, page, query }; } // Редактирование собственной незавершённой заявки — та же панель, но с формой. if (mode === 'edit' && formKey && itemId && !isNaN(itemId)) { return { mode: 'edit', nav, formKey, itemId, listId, ownTab, ownStatus, allTypeId, page, query }; } // Создание производной заявки с предзаполнением из исходной. rplist здесь — // партиция ИСХОДНОЙ заявки (Id в разных партициях повторяются). if (formKey && sourceId && !isNaN(sourceId)) { return { mode: 'create', nav: 'new', formKey, sourceId, listId }; } // Ссылка на пустую новую форму конкретного типа — без предзаполнения // (клик по плитке внутри портала, либо явная ссылка ?rptype=<форма> без // rpsource — см. newRequestLinkUrl). Как и ветка с rpsource выше, НЕ требует // rpmode=create: buildQuery() его для этого случая не пишет (значения по // умолчанию в адрес не пишем), поэтому единственный сигнал — сам rptype. // Но approve/edit явно исключаем: битая/неполная ссылка на них (напр. // rpmode=approve без валидного rpid) должна откатываться на обычную подачу, // а не тихо превращаться в открытие пустой формы другого режима. if (formKey && mode !== 'approve' && mode !== 'edit') { return { mode: 'create', nav: 'new', formKey, positionId, nomenclatureId, storeName, sourceSystem }; } return { mode: 'submit', nav, ownTab, ownStatus, allTypeId, page, query }; } // Собрать query-строку маршрута, сохранив посторонние параметры страницы. function buildQuery(route: IRoute): string { const params = new URLSearchParams(window.location.search); params.delete('rpmode'); params.delete('rpnav'); params.delete('rptype'); params.delete('rpid'); params.delete('rplist'); params.delete('rpsource'); params.delete('rptab'); params.delete('rpstatus'); params.delete('rppage'); params.delete('rpq'); params.delete('rpalltype'); if (route.nav !== 'new') { params.set('rpnav', route.nav); } // Значения по умолчанию в адрес не пишем — чтобы обычный переход на «Мои // заявки» давал чистую ссылку, а не ?rpnav=own&rptab=created. if (route.nav === 'own') { const ownTab = route.ownTab ?? 'created'; if (ownTab !== 'created') params.set('rptab', ownTab); else if (route.ownStatus) params.set('rpstatus', route.ownStatus); } if (route.nav === 'allRequests' && route.allTypeId) { params.set('rpalltype', String(route.allTypeId)); } // Первая страница — тоже значение по умолчанию (см. выше про чистую ссылку). if (route.page && route.page > 1) params.set('rppage', String(route.page)); if (route.query) params.set('rpq', route.query); if ((route.mode === 'approve' || route.mode === 'edit') && route.formKey && route.itemId) { params.set('rpmode', route.mode); params.set('rptype', route.formKey); params.set('rpid', String(route.itemId)); // Партиция неизвестна (архивная ссылка старого формата, до этого поля) — // ссылка всё равно валидна, форма/просмотр просто откатятся на текущую. if (route.listId) params.set('rplist', route.listId); } else if (route.mode === 'create' && route.formKey) { params.set('rptype', route.formKey); if (route.sourceId) { params.set('rpsource', String(route.sourceId)); if (route.listId) params.set('rplist', route.listId); } } const query = params.toString(); return `${window.location.pathname}${query ? `?${query}` : ''}${window.location.hash}`; } /** * Перейти на другой маршрут без перезагрузки страницы (SPA): обновляет * query-параметры адресной строки через history.pushState и оповещает * useRoute() своим событием (popstate на pushState сам не срабатывает). * * replace=true заменяет текущую запись истории вместо добавления новой — * для набора текста в поиске: иначе каждая буква становилась бы отдельным * шагом «назад», и чтобы уйти со страницы, пришлось бы стереть весь запрос * по одному нажатию. */ export function navigate(route: IRoute, options?: { replace?: boolean }): void { const url = buildQuery(route); if (options?.replace) { window.history.replaceState({}, '', url); } else { window.history.pushState({}, '', url); } window.dispatchEvent(new Event(ROUTE_CHANGE_EVENT)); } /** Ссылка на маршрут (для href — открытие в новой вкладке, копирование и т.п.). */ export function routeUrl(route: IRoute): string { return buildQuery(route); } /** * АБСОЛЮТНАЯ ссылка на просмотр заявки — для того, что переживёт текущую сессию: * колонка-гиперссылка в списке SharePoint, письмо, сообщение в чат. * * Отличается от routeUrl() двумя вещами, и обе важны именно для «хранимой» ссылки: * она с origin (в списке относительный путь бесполезен — его открывают из * представления SharePoint, а не со страницы портала) и без посторонних * query-параметров текущей страницы (routeUrl их намеренно сохраняет, потому что * это навигация ВНУТРИ открытой страницы). * * Страница портала берётся из адреса текущей — портал живёт на одной странице, * другого способа узнать её путь у клиентского кода нет. */ export function requestLinkUrl(formKey: string, itemId: number, listId?: string): string { return requestLinkOnPage(`${window.location.origin}${window.location.pathname}`, formKey, itemId, listId); } /** * То же, что requestLinkUrl, но на ЗАДАННОЙ странице портала, а не на текущей. * Нужна там, где текущий адрес может оказаться не страницей портала: внутри * Teams это служебная страница вкладки, и ссылка из неё в письме не откроется * (см. PORTAL_PAGE_URL в config.ts). */ export function requestLinkOnPage(pageUrl: string, formKey: string, itemId: number, listId?: string): string { const params = new URLSearchParams(); params.set('rpmode', 'approve'); params.set('rptype', formKey); params.set('rpid', String(itemId)); if (listId) { params.set('rplist', listId); } return `${pageUrl}?${params.toString()}`; } /** * АБСОЛЮТНАЯ ссылка на ПУСТУЮ новую форму конкретного типа — без предзаполнения, * в отличие от requestLinkUrl (просмотр существующей). Для той же цели: письмо, * сообщение в чат, кнопка «поделиться ссылкой на форму». */ export function newRequestLinkUrl(formKey: string): string { const params = new URLSearchParams(); params.set('rpmode', 'create'); params.set('rptype', formKey); return `${window.location.origin}${window.location.pathname}?${params.toString()}`; } /** * Открыть заявку по её АБСОЛЮТНОЙ ссылке (см. requestLinkUrl) БЕЗ перезагрузки * страницы — если ссылка ведёт на эту же страницу портала. * * Зачем: страница SharePoint со SPFx грузится секунды, а ссылка между парными * заявками (заявление на увольнение ↔ обходной лист) — это по сути та же страница * с другими rp-параметрами, то есть ровно то, что умеет navigate(). Полная * перезагрузка здесь ничего не даёт. * * Возвращает false, если ссылка ведёт куда-то ещё (другой сайт, другая страница, * не на просмотр заявки) — тогда вызывающий НЕ должен подавлять клик и обязан * дать браузеру перейти по ней обычным образом. */ export function navigateToRequestLink(url: string): boolean { let target: URL; try { target = new URL(url, window.location.href); } catch { return false; } // Путь в SharePoint регистронезависим, а ссылка могла быть записана в список // когда-то с другим регистром — сравниваем в нижнем. if ( target.origin !== window.location.origin || target.pathname.toLowerCase() !== window.location.pathname.toLowerCase() ) { return false; } const route = parseRoute(target.search); if (route.mode !== 'approve' || !route.formKey || !route.itemId) { return false; } // Раздел бокового меню берём ТЕКУЩИЙ, а не из ссылки: заявка показывается в // панели поверх раздела, и уход по ссылке не должен переключать раздел под ней. navigate({ ...route, nav: parseRoute().nav }); return true; } /** Текущий маршрут, реактивно обновляется по navigate() и кнопке «назад». */ export function useRoute(): IRoute { 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; }