diff --git a/src/webparts/requestPortal/components/RequestGrid/RequestGrid.module.scss b/src/webparts/requestPortal/components/RequestGrid/RequestGrid.module.scss index ac7dfee..edf2a0c 100644 --- a/src/webparts/requestPortal/components/RequestGrid/RequestGrid.module.scss +++ b/src/webparts/requestPortal/components/RequestGrid/RequestGrid.module.scss @@ -6,6 +6,34 @@ gap: 14px; } +.tileWrap { + position: relative; +} + +.allRequestsButton { + position: absolute; + top: 10px; + right: 10px; + z-index: 1; + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: 1px solid $gray-200; + border-radius: $r-md; + background: $white; + color: $gray-400; + cursor: pointer; + transition: color .18s, border-color .18s; + + &:hover { + color: $gold-600; + border-color: $gold-400; + } +} + .tile { position: relative; display: flex; diff --git a/src/webparts/requestPortal/components/RequestGrid/RequestGrid.tsx b/src/webparts/requestPortal/components/RequestGrid/RequestGrid.tsx index d188d78..fa4cf04 100644 --- a/src/webparts/requestPortal/components/RequestGrid/RequestGrid.tsx +++ b/src/webparts/requestPortal/components/RequestGrid/RequestGrid.tsx @@ -7,9 +7,18 @@ import { useIsTester } from '../../shared/useIsTester'; interface IRequestGridProps { requests: IRequestType[]; onSelect: (request: IRequestType) => void; + /** pageUrl → RequestTypeId, только для типов с доступом к «Все заявки» (см. useAccessibleAllRequestsFormKeys). */ + accessibleAllRequestsIds?: Map; + /** Клик по кнопке «Все заявки» на плитке — задан вместе с accessibleAllRequestsIds. */ + onOpenAllRequests?: (requestTypeId: number) => void; } -export const RequestGrid: React.FC = ({ requests, onSelect }) => { +export const RequestGrid: React.FC = ({ + requests, + onSelect, + accessibleAllRequestsIds, + onOpenAllRequests, +}) => { // Группа «Тестирование» видит формы «в разработке» открытыми — чтобы проверять // их до запуска для всех (см. shared/useIsTester). const isTester = useIsTester() === true; @@ -22,33 +31,45 @@ export const RequestGrid: React.FC = ({ requests, onSelect })
{requests.map((request) => { const isLocked = !!request.comingSoon && !isTester; + const allRequestsTypeId = accessibleAllRequestsIds?.get(request.id); return ( - + {allRequestsTypeId !== undefined && onOpenAllRequests && ( + )} - +
); })} diff --git a/src/webparts/requestPortal/components/RequestPortal.tsx b/src/webparts/requestPortal/components/RequestPortal.tsx index 6cef99a..2cfdb51 100644 --- a/src/webparts/requestPortal/components/RequestPortal.tsx +++ b/src/webparts/requestPortal/components/RequestPortal.tsx @@ -7,11 +7,13 @@ import type { ISidebarItem } from '@tnsu/ui-kit'; import { ConfirmHost } from '../shared/ui'; import { navigate, + persistAllTypeId, persistNav, persistOwnTab, persistOwnStatus, persistPage, persistQuery, + storedAllTypeId, storedOwnStatus, storedOwnTab, storedPageFor, @@ -230,6 +232,19 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context }); 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, @@ -255,8 +270,12 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context // Переход в «Все заявки» — либо с бокового меню (typeId не задан, раздел сам // выберет тип), либо с кнопки формы (typeId уже известен, см. - // gotoAllRequestsForType и SubmitFeature.onOpenAllRequests). + // gotoAllRequestsForType и SubmitFeature.onOpenAllRequests), либо смена типа + // в самом разделе (пикер в AllRequestsFeature). Запоминаем выбор — как и + // persistOwnTab, только при явном выборе (в т.ч. том, что раздел сделал + // сам по умолчанию), чтобы следующий заход открыл тот же тип. const gotoAllRequestsType = (typeId: number): void => { + persistAllTypeId(typeId); navigate({ mode: 'submit', nav: 'allRequests', diff --git a/src/webparts/requestPortal/features/submit/SubmitFeature.tsx b/src/webparts/requestPortal/features/submit/SubmitFeature.tsx index 12cc62e..ea2d8bc 100644 --- a/src/webparts/requestPortal/features/submit/SubmitFeature.tsx +++ b/src/webparts/requestPortal/features/submit/SubmitFeature.tsx @@ -10,7 +10,7 @@ import { IRequestType } from '../../models/types'; import { emitRequestsChanged } from '../../shared/requestEvents'; import { confirmDiscardFormChanges, markFormClean } from '../../shared/unsavedChanges'; import { useIsTester } from '../../shared/useIsTester'; -import { getRequestTypeIdByPageUrl, hasRequestTypeAccess } from '../../shared/approval'; +import { useAccessibleAllRequestsFormKeys } from '../../shared/approval'; export interface ISubmitFeatureProps { context: WebPartContext; @@ -68,33 +68,12 @@ const SubmitFeature: React.FC = ({ const requests = getRequestsByCategory(activeCategoryId); const isTester = useIsTester(); - // Кнопка «Все заявки этого типа» — только на форме открытого типа, и только - // если текущему пользователю в админке approval-route выдан доступ к нему - // (см. shared/approval/requestTypeAccess.ts). requestType.id формы совпадает - // с pageUrl approval-route по соглашению (см. useRequestTypeId). - const [allRequestsTypeId, setAllRequestsTypeId] = useState(undefined); - const [canOpenAllRequests, setCanOpenAllRequests] = useState(false); - useEffect(() => { - let cancelled = false; - setCanOpenAllRequests(false); - setAllRequestsTypeId(undefined); - if (!selectedRequest) return; - - getRequestTypeIdByPageUrl(context, selectedRequest.id) - .then((id) => { - if (cancelled) return undefined; - setAllRequestsTypeId(id); - return hasRequestTypeAccess(context, id); - }) - .then((allowed) => { - if (!cancelled && allowed) setCanOpenAllRequests(true); - }) - .catch(() => undefined); - - return () => { - cancelled = true; - }; - }, [context, selectedRequest]); + // Кнопка «Все заявки этого типа» — и на плитке, и на открытой форме, только + // если текущему пользователю в админке approval-route выдан доступ к этому + // типу (см. shared/approval/requestTypeAccess.ts). Один общий поход по всем + // типам разом (formKey формы совпадает с pageUrl approval-route по + // соглашению, см. useRequestTypeId) — не по одному запросу на каждую плитку. + const accessibleAllRequestsIds = useAccessibleAllRequestsFormKeys(context); // Возврат к плиткам закрывает форму и теряет введённое (раздел остаётся // смонтированным, но состояние формы живёт в самом FormComponent). Спрашиваем, @@ -138,10 +117,12 @@ const SubmitFeature: React.FC = ({ // которой они пришли (formKey совпал). const external = initialForm && request.id === initialForm.formKey ? initialForm : undefined; + const allRequestsTypeId = accessibleAllRequestsIds?.get(request.id); + // Ленивый рендер формы: её код подтягивается только сейчас, при открытии. return ( <> - {canOpenAllRequests && allRequestsTypeId && onOpenAllRequests && ( + {allRequestsTypeId !== undefined && onOpenAllRequests && (
); diff --git a/src/webparts/requestPortal/shared/approval/index.ts b/src/webparts/requestPortal/shared/approval/index.ts index 549bcee..4e3bc5c 100644 --- a/src/webparts/requestPortal/shared/approval/index.ts +++ b/src/webparts/requestPortal/shared/approval/index.ts @@ -35,6 +35,7 @@ export { resolveRoute } from './approvalRoute.service'; export { hasRequestTypeAccess, accessibleAllRequestsTypes } from './requestTypeAccess'; +export { useAccessibleAllRequestsFormKeys } from './useAccessibleAllRequestsFormKeys'; export { initListPartitions, listPartitionsLoaded, diff --git a/src/webparts/requestPortal/shared/approval/useAccessibleAllRequestsFormKeys.ts b/src/webparts/requestPortal/shared/approval/useAccessibleAllRequestsFormKeys.ts new file mode 100644 index 0000000..1d670a5 --- /dev/null +++ b/src/webparts/requestPortal/shared/approval/useAccessibleAllRequestsFormKeys.ts @@ -0,0 +1,35 @@ +import { useEffect, useState } from 'react'; +import type { WebPartContext } from '@microsoft/sp-webpart-base'; +import { getRequestTypesCached } from './approvalRoute.service'; +import { accessibleAllRequestsTypes } from './requestTypeAccess'; + +/** + * pageUrl (formKey формы) → числовой RequestTypeId approval-route, только для + * типов, чьи заявки текущему пользователю разрешено видеть целиком (см. + * requestTypeAccess.ts). Один поход по всем типам разом — плитки и кнопка на + * открытой форме (SubmitFeature) решают по нему, показывать ли переход в + * «Все заявки», не дожидаясь резолва конкретного типа. + * + * undefined — ответ ещё не пришёл (плитки без кнопки, а не с ошибочно пустой). + */ +export function useAccessibleAllRequestsFormKeys(context: WebPartContext): Map | undefined { + const [map, setMap] = useState | undefined>(undefined); + + useEffect(() => { + let cancelled = false; + getRequestTypesCached(context) + .then((types) => accessibleAllRequestsTypes(context, types)) + .then((accessible) => { + if (cancelled) return; + setMap(new Map(accessible.map((t) => [t.pageUrl, t.id]))); + }) + .catch(() => { + if (!cancelled) setMap(new Map()); + }); + return () => { + cancelled = true; + }; + }, [context]); + + return map; +} diff --git a/src/webparts/requestPortal/shared/routing.ts b/src/webparts/requestPortal/shared/routing.ts index afa53f2..e8a2371 100644 --- a/src/webparts/requestPortal/shared/routing.ts +++ b/src/webparts/requestPortal/shared/routing.ts @@ -298,6 +298,37 @@ function readStoredNav(): NavSection { } } +// Выбранный тип заявки в разделе «Все заявки» — тем же приёмом, что и +// подвкладка/фильтр «Моих заявок» (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 { @@ -355,12 +386,17 @@ export function parseRoute(search: string = window.location.search): IRoute { const ownTab = nav === 'own' ? parseOwnTab(params.get('rptab') ?? undefined) : undefined; const ownStatus = ownTab === 'created' ? params.get('rpstatus') ?? readStoredOwnStatus() : undefined; - // Тип заявки в разделе «Все заявки» — только из адреса: в отличие от - // ownTab/ownStatus, здесь не читаем «запомненное» значение при его отсутствии - // (список доступных типов не известен без похода в API, поэтому выбор - // значения по умолчанию отдаём самому разделу, см. AllRequestsFeature). - const allTypeIdRaw = nav === 'allRequests' ? Number(params.get('rpalltype')) : NaN; - const allTypeId = Number.isInteger(allTypeIdRaw) && allTypeIdRaw > 0 ? allTypeIdRaw : 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, минус, буквы) трактуем как первую страницу, а не как ошибку: