5d2abaf430
Tests / frontend-test (push) Successful in 2m1s
## Что сделано **Чат заявки: отметки и оповещения** - В поле ввода «@» открывает подсказку с участниками заявки: инициатор, согласующие и исполнители из маршрута, а также те, кому открыты «Все заявки» этого типа (список из request-type-access). - Отметка — обычный текст «@ФИО», без штатного упоминания SharePoint, чтобы не уходило его письмо. - После отправки сообщения портал вызывает поток Power Automate (`FLOW_CHAT_NOTIFY_URL`): отмеченным — «Вас упомянули», инициатору — «Новое сообщение по вашей заявке». Сбой потока не мешает отправке. - В письмо добавляется строка «Прикреплены файлы: …», чтобы сообщение из одного файла не приходило пустым. - Свойство веб-части «Чат: уведомлять о своих сообщениях (для теста)», по умолчанию выключено: письма и о своих сообщениях, можно отметить себя. - Когда пишет сам инициатор с отметками, в `initiatorEmail` уходит первый отмеченный — поток шлёт инициатору всегда, если его нет среди отмеченных, и иначе он получил бы письмо о своём же сообщении. **Чат включён для:** Обходной лист, заявка на подбор, заявка на приём, заявка в отдел 1С. **Исправление:** редактор маршрута может удалять исполнителей в Обходном листе, Командировке и заявке в 1С (не передавалось `canRemoveRow`). **Прочее:** убраны 4 предупреждения линтера (`no-new-null`, `no-script-url`). ## Не проверено на тенанте - Ответ потока 200 через AadHttpClient и доставка писем. - Права согласующих на комментарии и на запись в библиотеку «Файлы чата». - Папки в «Файлы чата» наследуют права библиотеки, а не закрыты на заявку — для подбора и приёма это важно решить до выкладки. - Если поток вставляет `messageText` в HTML-письмо, перенос строки перед «Прикреплены файлы» может не отобразиться. --------- Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz> Reviewed-on: http://git.tnsu.kz/timur.gorodetskiy/request-portal/pulls/26
625 lines
36 KiB
TypeScript
625 lines
36 KiB
TypeScript
import * as React from 'react';
|
||
|
||
// Маршрутизация портала по параметрам URL.
|
||
//
|
||
// Раздел бокового меню — rpnav: 'new' (подать заявку, по умолчанию),
|
||
// 'approvals' (ждут моего решения), 'own' (мои заявки), 'search' (поиск
|
||
// сразу по обеим категориям — и по своим заявкам, и по тем, где участвовал).
|
||
// Открытая заявка — переход по ссылке из потока Power Automate или клик по
|
||
// строке таблицы:
|
||
// …/СтраницаПортала.aspx?rpmode=approve&rptype=<форма>&rpid=<RowID>
|
||
// где 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<IRoute>(() => 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;
|
||
}
|