import * as React from 'react'; import styles from './RequestPortal.module.scss'; import type { IRequestPortalProps } from './IRequestPortalProps'; import { IconSprite } from '../shared/ui/Icon/IconSprite'; import { navigate, persistOwnTab, persistOwnStatus, persistPage, persistQuery, storedOwnStatus, storedOwnTab, storedPageFor, storedQueryFor, useRoute, NavSection, OwnTab, } from '../shared/routing'; import { onRequestsChanged, emitRequestsChanged } from '../shared/requestEvents'; import { confirmDiscardFormChanges, markFormClean, markFormDirty } from '../shared/unsavedChanges'; import { SideNav } from './SideNav/SideNav'; import { Drawer } from './Drawer/Drawer'; import { CategoryTabs } from './CategoryTabs/CategoryTabs'; 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 { getNavCounts, INavCounts } from '../features/myRequests/myRequests.service'; import { IMyRequest } from '../features/myRequests/models/IMyRequest'; import { isEditableStatus } 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(' '); const route = useRoute(); const [navCollapsed, setNavCollapsed] = React.useState(false); const [navCounts, setNavCounts] = React.useState(undefined); 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 => { if (!confirmDiscardFormChanges()) return; markFormClean(); setActiveCategoryId(categoryId); }; const goto = (nav: NavSection): void => { // Клик по УЖЕ активному пункту меню не меняет маршрут, значит эффект // загрузки в таблице не перезапустится сам. Шлём событие обновления — // таблица на него подписана и перечитает данные фоном, не убирая с экрана // текущие строки (см. RequestsTableView/SearchView). if (nav === route.nav && route.mode === 'submit') emitRequestsChanged(); // Всё восстанавливаемое состояние раздела передаём ЯВНО, из запомненного. // 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; } 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, }); }; 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, ownTab: route.ownTab, ownStatus: route.ownStatus, // Страница и запрос — часть того же списка под панелью: без них ссылка на // открытую заявку вела бы на первую страницу без поиска. page: route.page, query: route.query, }); // Закрывая панель, возвращаемся ровно в тот список, из которого её открыли — // включая подвкладку и фильтр «Моих заявок». const closeDrawer = (): void => navigate({ mode: 'submit', nav: route.nav, ownTab: route.ownTab, ownStatus: route.ownStatus, page: route.page, query: route.query, }); // Закрытие панели РЕДАКТИРОВАНИЯ: там открыта форма, и её содержимое живёт // только в памяти — уходя без сохранения, пользователь теряет правки. const closeEditDrawer = (): void => { if (!confirmDiscardFormChanges()) return; markFormClean(); closeDrawer(); }; return (
setNavCollapsed((v) => !v)} />
{/* Раздел «Новая заявка» держим СМОНТИРОВАННЫМ и просто прячем стилем, когда открыта другая вкладка. Иначе React размонтировал бы форму, и всё введённое пропадало бы при переключении на «Мои заявки» и обратно. Ключ завязан на «создать на основе»: обычные переключения вкладок его не меняют (состояние живёт), а вот открытие новой заявки на основе исходной пересоздаёт форму — SubmitFeature читает initialForm только при монтировании. */}
{route.nav === 'search' && ( )} {route.nav === 'own' && ( )} {LIST_VIEWS[route.nav] !== undefined && ( )}
{route.mode === 'approve' && route.formKey && route.itemId && ( )} {/* Своя незавершённая заявка — та же панель, но с формой. Ввод здесь тоже помечает состояние изменённым, поэтому обёртка ловит input/change. */} {route.mode === 'edit' && route.formKey && route.itemId && (
)}
); }; export default RequestPortal;