import * as React from 'react'; import styles from './RequestPortal.module.scss'; import '@tnsu/ui-kit/styles.css'; import type { IRequestPortalProps } from './IRequestPortalProps'; import { IconSprite, Sidebar, SidebarToggle } from '@tnsu/ui-kit'; import type { ISidebarItem } from '@tnsu/ui-kit'; import { ConfirmHost } from '../shared/ui'; import { navigate, persistAllTypeId, persistNav, persistOwnTab, persistOwnStatus, persistPage, persistQuery, storedAllTypeId, storedOwnStatus, storedOwnTab, storedPageFor, storedQueryFor, useRoute, NavSection, OwnTab, } from '../shared/routing'; import { onRequestsChanged, emitRequestsChanged } from '../shared/requestEvents'; import { USER_GUIDE_URL } from '../shared/config'; import { getImpersonatedEmail, getImpersonatedDisplayName } from '../shared/impersonation'; import { confirmDiscardFormChanges, markFormClean, markFormDirty } from '../shared/unsavedChanges'; import { Drawer } from './Drawer/Drawer'; import { CategoryTabs } from './CategoryTabs/CategoryTabs'; import { TeamsBrowserNotice } from './TeamsBrowserNotice/TeamsBrowserNotice'; import { isOpenedInTeams } from '../shared/hostEnvironment'; import { CATEGORIES, DEFAULT_CATEGORY_ID } from '../data/categories'; import { REQUEST_TYPES } from '../data/requests'; import SubmitFeature from '../features/submit/SubmitFeature'; import ApproveFeature from '../features/approve/ApproveFeature'; import EditFeature from '../features/edit/EditFeature'; import { ListView, RequestsTableView } from '../features/myRequests/RequestsTableView'; import { OwnTabs } from '../features/myRequests/OwnTabs'; import { SearchView } from '../features/myRequests/SearchView'; import { getLinkedTypes, getNavCounts, INavCounts } from '../features/myRequests/myRequests.service'; import { IMyRequest } from '../features/myRequests/models/IMyRequest'; import { isEditableStatus } from '../shared/approval'; import { hasReportsAccess } from '../shared/reports/reportsAccess'; import { ReportsFeature } from '../features/reports/ReportsFeature'; import { AllRequestsFeature } from '../features/myRequests/AllRequestsFeature'; import { accessibleAllRequestsTypes } from '../shared/approval'; // Оболочка портала: тёмное боковое меню (разделы) + основная область. // Логики фич тут нет — файл общий, меняется редко. // // Разделы (rpnav): 'new' — подача заявки (вкладки категорий + плитки + форма), // 'approvals'/'own' — таблицы заявок. Клик по строке таблицы открывает заявку // в выезжающей панели ПОВЕРХ раздела (rpmode=approve&rptype=&rpid=), поэтому // раздел под ней сохраняется; та же ссылка приходит из потока Power Automate. // Разделы, которые показывают таблицу заявок, и соответствующее ей // представление. У 'own' оно зависит ещё и от подвкладки (см. ниже), поэтому // здесь стоит заглушка — важен сам факт наличия ключа. const LIST_VIEWS: Partial> = { approvals: 'approvals', execution: 'execution', rework: 'rework', own: 'own:created', }; const RequestPortal: React.FC = ({ hasTeamsContext, context }) => { const rootClasses = [styles.requestPortal, hasTeamsContext ? styles.teams : ''] .filter(Boolean) .join(' '); // Читаем при рендере, а не через состояние: значение выставляет веб-часть // (setImpersonatedEmail в render) до создания этого элемента, и меняется оно // только вместе с новым render всей веб-части. const impersonatedEmail = getImpersonatedEmail(); const impersonatedDisplayName = getImpersonatedDisplayName(); const route = useRoute(); const [navCollapsed, setNavCollapsed] = React.useState(false); const [mobileNavOpen, setMobileNavOpen] = React.useState(false); const [navCounts, setNavCounts] = React.useState(undefined); // Раздел «Отчёты» — группа «Отчеты по заявкам» + администраторы (см. // reportsAccess.ts). undefined — ответ ещё не пришёл. Перезапрашиваем при // смене подменённой личности. const [canSeeReports, setCanSeeReports] = React.useState(undefined); React.useEffect(() => { let cancelled = false; setCanSeeReports(undefined); hasReportsAccess(context) .then((v) => { if (!cancelled) setCanSeeReports(v); }) .catch(() => { if (!cancelled) setCanSeeReports(false); }); return () => { cancelled = true; }; }, [context, impersonatedEmail]); // Прямой переход по ссылке (?rpnav=reports) в обход бокового меню — тот же // раздел, что скрыт из навигации, должен быть недоступен и так: без этого // редиректа он открылся бы у любого, кто угадает или сохранит такую ссылку. // Пока ответ не пришёл — не редиректим, иначе участника группы выкинет. React.useEffect(() => { if (route.nav === 'reports' && canSeeReports === false) { navigate({ mode: 'submit', nav: 'new' }, { replace: true }); } }, [route.nav, canSeeReports]); // Раздел «Все заявки» — виден только тем, кому в админке approval-route // выдан RequestTypeAccess хотя бы на один тип (см. shared/approval/ // requestTypeAccess.ts). undefined — ответ ещё не пришёл. const [canSeeAllRequests, setCanSeeAllRequests] = React.useState(undefined); React.useEffect(() => { let cancelled = false; setCanSeeAllRequests(undefined); getLinkedTypes(context) .then((types) => accessibleAllRequestsTypes(context, types)) .then((accessible) => { if (!cancelled) setCanSeeAllRequests(accessible.length > 0); }) .catch(() => { if (!cancelled) setCanSeeAllRequests(false); }); return () => { cancelled = true; }; }, [context, impersonatedEmail]); // Та же защита от прямой ссылки, что и у «Отчётов» выше. React.useEffect(() => { if (route.nav === 'allRequests' && canSeeAllRequests === false) { navigate({ mode: 'submit', nav: 'new' }, { replace: true }); } }, [route.nav, canSeeAllRequests]); // Уникальный код заявки для заголовка Drawer (approve/edit) — сама форма // узнаёт его только после загрузки (см. onCodeLoaded в ApproveFeature/ // EditFeature), поэтому не берём route.itemId (это RowID в SharePoint, а не // видимый пользователю код). Сбрасываем при переходе на другую заявку — // иначе при быстрой смене итемов в заголовке на миг мелькнёт код ПРЕДЫДУЩЕЙ. const [drawerCode, setDrawerCode] = React.useState(''); React.useEffect(() => { setDrawerCode(''); }, [route.itemId, route.mode]); const [activeCategoryId, setActiveCategoryId] = React.useState(() => { if (route.mode === 'create' && route.formKey) { const found = REQUEST_TYPES.filter((r) => r.id === route.formKey)[0]; if (found) return found.categoryId; } return DEFAULT_CATEGORY_ID; }); // Счётчики на пунктах-очередях («На согласовании», «На исполнении», // «Доработка»). Обновляются по тому же событию, что и сами таблицы (создание // заявки, решение согласующего), и на монтировании (в т.ч. на обновление // страницы). Все три приходят из одного вызова getNavCounts — он же следит, // чтобы цифры не разошлись с содержимым вкладок. React.useEffect(() => { let cancelled = false; const load = (): void => { getNavCounts(context) .then((c) => { if (!cancelled) setNavCounts(c); }) .catch(() => { if (!cancelled) setNavCounts(undefined); }); }; load(); const unsubscribe = onRequestsChanged(load); return () => { cancelled = true; unsubscribe(); }; }, [context]); // Переход «создать на основе» (в т.ч. из открытой заявки в панели): переключаем // вкладку категорий на категорию целевой формы, чтобы она совпала с открытой формой. // Форма при этом пересоздаётся (см. key у SubmitFeature), поэтому прежние // несохранённые правки к ней уже не относятся — снимаем флаг. React.useEffect(() => { if ((route.mode === 'create' || route.mode === 'edit') && route.formKey) { const found = REQUEST_TYPES.filter((r) => r.id === route.formKey)[0]; if (found) setActiveCategoryId(found.categoryId); markFormClean(); } }, [route.mode, route.formKey]); // Сохранение (в том числе черновика) шлёт rp-requests-changed — после него // терять нечего, предупреждать при «создать на основе» больше не о чем. React.useEffect(() => onRequestsChanged(markFormClean), []); // Смена категории закрывает открытую форму (SubmitFeature сбрасывает выбор, // когда категория перестаёт совпадать) — значит введённое пропадёт. const changeCategory = (categoryId: string): void => { confirmDiscardFormChanges().then((ok) => { if (!ok) return; markFormClean(); setActiveCategoryId(categoryId); }).catch(() => undefined); }; const goto = (nav: NavSection): void => { // Запоминаем раздел, чтобы следующий заход на портал (без rpnav в адресе) // открыл его же, а не всегда «Новую заявку» (см. persistNav в routing.ts). persistNav(nav); // Клик по УЖЕ активному пункту меню не меняет маршрут, значит эффект // загрузки в таблице не перезапустится сам. Шлём событие обновления — // таблица на него подписана и перечитает данные фоном, не убирая с экрана // текущие строки (см. RequestsTableView/SearchView). if (nav === route.nav && route.mode === 'submit') emitRequestsChanged(true); // Всё восстанавливаемое состояние раздела передаём ЯВНО, из запомненного. // parseRoute подставил бы его и сам, но тогда оно осталось бы только в // состоянии и не попало бы в адрес: ссылка на раздел вела бы на первую // страницу без фильтра, а на экране была бы открыта другая. // // Брать route.ownTab/route.ownStatus здесь нельзя: при переходе ИЗ другого // раздела они undefined (parseRoute заполняет их только при nav==='own'), // и явная передача сбросила бы выбор пользователя. if (nav === 'own') { const ownTab = storedOwnTab(); navigate({ mode: 'submit', nav, ownTab, ownStatus: storedOwnStatus(ownTab), page: storedPageFor('own', ownTab), query: storedQueryFor('own', ownTab) || undefined, }); return; } // Тип заявки в «Все заявки» — из запомненного (см. gotoAllRequestsType // ниже, откуда он и запоминается). Не задан ни разу — undefined, раздел // сам выберет первый доступный тип (см. AllRequestsFeature). if (nav === 'allRequests') { navigate({ mode: 'submit', nav, allTypeId: storedAllTypeId(), page: storedPageFor(nav), query: storedQueryFor(nav) || undefined, }); return; } navigate({ mode: 'submit', nav, page: storedPageFor(nav), query: storedQueryFor(nav) || undefined, }); }; const gotoOwnTab = (ownTab: OwnTab): void => { // Фильтр и страница — из запомненного для ЭТОЙ подвкладки (см. goto выше о // том, почему не из route). На вкладках с решениями фильтра нет по смыслу — // storedOwnStatus сам вернёт undefined. persistOwnTab(ownTab); navigate({ mode: 'submit', nav: 'own', ownTab, ownStatus: storedOwnStatus(ownTab), page: storedPageFor('own', ownTab), query: storedQueryFor('own', ownTab) || undefined, }); }; // Переход в «Все заявки» — либо с бокового меню (typeId не задан, раздел сам // выберет тип), либо с кнопки формы (typeId уже известен, см. // gotoAllRequestsForType и SubmitFeature.onOpenAllRequests), либо смена типа // в самом разделе (пикер в AllRequestsFeature). Запоминаем выбор — как и // persistOwnTab, только при явном выборе (в т.ч. том, что раздел сделал // сам по умолчанию), чтобы следующий заход открыл тот же тип. const gotoAllRequestsType = (typeId: number): void => { persistAllTypeId(typeId); navigate({ mode: 'submit', nav: 'allRequests', allTypeId: typeId, page: storedPageFor('allRequests'), query: storedQueryFor('allRequests') || undefined, }); }; const gotoAllRequestsForType = (typeId: number): void => { persistNav('allRequests'); gotoAllRequestsType(typeId); }; const setOwnStatus = (ownStatus: string): void => { const normalized = ownStatus || undefined; persistOwnStatus(normalized); // Смена фильтра — другой набор заявок, поэтому явно с первой страницы // (и запоминаем именно её, иначе вернувшись сюда попали бы на старую). persistPage({ mode: 'submit', nav: 'own', ownTab: 'created' }, 1); // Текст поиска переносим ЯВНО: раздел тот же, поле на экране не очищается — // значит и в адресе запрос должен остаться. Без этого rpq пропадал из // ссылки при каждой смене фильтра (в самом состоянии он всё равно // восстанавливался из запомненного, поэтому расхождение адреса с экраном // было видно не сразу). navigate({ mode: 'submit', nav: 'own', ownTab: 'created', ownStatus: normalized, page: 1, query: route.query || undefined, }); }; // Смена страницы сохраняет ВЕСЬ остальной маршрут (в т.ч. открытую заявку), // меняется только номер: так «назад» возвращает на прежнюю страницу, а // ссылку со страницей можно передать. const setPage = (page: number): void => { persistPage(route, page); navigate({ ...route, page }); }; // Новый запрос — другой набор результатов, поэтому вместе с ним всегда первая // страница (иначе можно приземлиться на несуществующую). // // replace, а не push: текст уходит сюда после КАЖДОЙ паузы в наборе, и с // обычным переходом каждое слово становилось бы отдельным шагом «назад» — // чтобы уйти со страницы, пришлось бы «отлистать» весь набранный запрос. const setQuery = (query: string): void => { persistQuery(route, query); persistPage(route, 1); navigate({ ...route, query: query || undefined, page: 1 }, { replace: true }); }; // Клик по строке таблицы. СВОЯ заявка в незавершённом статусе (черновик, // возвращённая, отклонённая) открывается на редактирование, всё остальное — // на просмотр: чужую заявку править нельзя, а свою уже ушедшую по маршруту — // тем более (её статус в isEditableStatus не входит). const openRequest = (request: IMyRequest): void => navigate({ mode: request.kind === 'own' && isEditableStatus(request.status) ? 'edit' : 'approve', nav: route.nav, formKey: request.formKey, itemId: request.id, // Заявки нумерованные (см. shared/approval/listPartitions.ts) — строка // таблицы уже знает, в каком СПИСКЕ она реально лежит (см. IMyRequest.listId // в myRequests.service.ts), и это надо передать дальше, иначе архивная // заявка откроется так, будто лежит в текущей партиции. listId: request.listId, ownTab: route.ownTab, ownStatus: route.ownStatus, allTypeId: route.allTypeId, // Страница и запрос — часть того же списка под панелью: без них ссылка на // открытую заявку вела бы на первую страницу без поиска. page: route.page, query: route.query, }); // Закрывая панель, возвращаемся ровно в тот список, из которого её открыли — // включая подвкладку и фильтр «Моих заявок». const closeDrawer = (): void => navigate({ mode: 'submit', nav: route.nav, ownTab: route.ownTab, ownStatus: route.ownStatus, allTypeId: route.allTypeId, page: route.page, query: route.query, }); // Закрытие панели РЕДАКТИРОВАНИЯ: там открыта форма, и её содержимое живёт // только в памяти — уходя без сохранения, пользователь теряет правки. const closeEditDrawer = (): void => { confirmDiscardFormChanges().then((ok) => { if (!ok) return; markFormClean(); closeDrawer(); }).catch(() => undefined); }; // Разделы бокового меню для пакетного Sidebar (счётчики — из navCounts). // «Отчёты» — только у тех, кому разрешено (см. canSeeReports выше): скрыт из // меню совсем, а не показан неактивным, — раздела для остальных как бы не существует. // // «Инструкция пользователя» — не раздел портала, а внешняя ссылка (docx в базе // знаний). id 'instructions' НЕ входит в NavSection специально: обработчик // onChange ниже перехватывает его до вызова goto() и открывает ссылку в новой // вкладке, не меняя текущий раздел/маршрут. const navItems: ISidebarItem[] = [ { id: 'new', title: 'Новая заявка', icon: 'plus' }, { id: 'approvals', title: 'На согласовании', icon: 'user-check', count: navCounts?.approvals }, { id: 'execution', title: 'На исполнении', icon: 'helmet', count: navCounts?.execution }, { id: 'rework', title: 'Доработка', icon: 'edit', count: navCounts?.rework }, { id: 'own', title: 'Мои заявки', icon: 'clipboard' }, { id: 'search', title: 'Поиск', icon: 'search' }, ...(canSeeAllRequests ? [{ id: 'allRequests', title: 'Все заявки', icon: 'menu' } as ISidebarItem] : []), ...(canSeeReports ? [{ id: 'reports', title: 'Отчёты', icon: 'chart' } as ISidebarItem] : []), { id: 'instructions', title: 'Инструкция пользователя', icon: 'info' }, ]; const handleNavChange = (id: string): void => { if (id === 'instructions') { window.open(USER_GUIDE_URL, '_blank', 'noopener'); return; } goto(id as NavSection); }; return (
{/* Подмену личности легко забыть включённой — тогда портал молча показывал бы чужие заявки и чужую очередь. Полосу поэтому не прячем и не даём закрыть: она исчезает только когда свойство веб-части очищено. */} {impersonatedEmail && (
Включена отладочная подмена: портал работает от имени{' '} {impersonatedDisplayName ? `${impersonatedDisplayName} (${impersonatedEmail})` : impersonatedEmail} . Очистите поле в свойствах веб-части, чтобы вернуться к своей учётной записи.
)}
setNavCollapsed((v) => !v)} mobileOpen={mobileNavOpen} onMobileClose={() => setMobileNavOpen(false)} />
setMobileNavOpen(true)} />
{/* Раздел «Новая заявка» держим СМОНТИРОВАННЫМ и просто прячем стилем, когда открыта другая вкладка. Иначе React размонтировал бы форму, и всё введённое пропадало бы при переключении на «Мои заявки» и обратно. Ключ завязан на «создать на основе»: обычные переключения вкладок его не меняют (состояние живёт), а вот открытие новой заявки на основе исходной пересоздаёт форму — SubmitFeature читает initialForm только при монтировании. */}
{isOpenedInTeams(hasTeamsContext) && }
navigate({ mode: 'create', nav: 'new', formKey })} onOpenAllRequests={gotoAllRequestsForType} />
{route.nav === 'search' && ( )} {route.nav === 'own' && ( )} {route.nav === 'reports' && canSeeReports && } {route.nav === 'allRequests' && canSeeAllRequests && ( )} {LIST_VIEWS[route.nav] !== undefined && ( )}
{route.mode === 'approve' && route.formKey && route.itemId && ( )} {/* Своя незавершённая заявка — та же панель, но с формой. Ввод здесь тоже помечает состояние изменённым, поэтому обёртка ловит input/change. */} {route.mode === 'edit' && route.formKey && route.itemId && (
)}
); }; export default RequestPortal;