Feature/request type access all requests #22

Merged
gorbenko.pavel merged 3 commits from feature/request-type-access-all-requests into main 2026-09-25 11:48:10 +00:00
7 changed files with 188 additions and 62 deletions
Showing only changes of commit d079b3666a - Show all commits
@@ -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;
@@ -7,9 +7,18 @@ import { useIsTester } from '../../shared/useIsTester';
interface IRequestGridProps {
requests: IRequestType[];
onSelect: (request: IRequestType) => void;
/** pageUrl → RequestTypeId, только для типов с доступом к «Все заявки» (см. useAccessibleAllRequestsFormKeys). */
accessibleAllRequestsIds?: Map<string, number>;
/** Клик по кнопке «Все заявки» на плитке — задан вместе с accessibleAllRequestsIds. */
onOpenAllRequests?: (requestTypeId: number) => void;
}
export const RequestGrid: React.FC<IRequestGridProps> = ({ requests, onSelect }) => {
export const RequestGrid: React.FC<IRequestGridProps> = ({
requests,
onSelect,
accessibleAllRequestsIds,
onOpenAllRequests,
}) => {
// Группа «Тестирование» видит формы «в разработке» открытыми — чтобы проверять
// их до запуска для всех (см. shared/useIsTester).
const isTester = useIsTester() === true;
@@ -22,33 +31,45 @@ export const RequestGrid: React.FC<IRequestGridProps> = ({ requests, onSelect })
<div className={styles.grid}>
{requests.map((request) => {
const isLocked = !!request.comingSoon && !isTester;
const allRequestsTypeId = accessibleAllRequestsIds?.get(request.id);
return (
<button
key={request.id}
type="button"
className={isLocked ? `${styles.tile} ${styles.tileDisabled}` : styles.tile}
disabled={isLocked}
onClick={() => onSelect(request)}
>
<span className={styles.tileIcon}>
<Icon name={request.icon} />
</span>
<span className={styles.tileBody}>
<span className={styles.tileTitle}>{request.title}</span>
{request.comingSoon ? (
<Badge variant="muted" size="sm">
В разработке
</Badge>
) : (
request.description && <span className={styles.tileDesc}>{request.description}</span>
)}
</span>
{!isLocked && (
<span className={styles.tileArrow}>
<Icon name="chevron-right" size="sm" />
<div key={request.id} className={styles.tileWrap}>
<button
type="button"
className={isLocked ? `${styles.tile} ${styles.tileDisabled}` : styles.tile}
disabled={isLocked}
onClick={() => onSelect(request)}
>
<span className={styles.tileIcon}>
<Icon name={request.icon} />
</span>
<span className={styles.tileBody}>
<span className={styles.tileTitle}>{request.title}</span>
{request.comingSoon ? (
<Badge variant="muted" size="sm">
В разработке
</Badge>
) : (
request.description && <span className={styles.tileDesc}>{request.description}</span>
)}
</span>
{!isLocked && (
<span className={styles.tileArrow}>
<Icon name="chevron-right" size="sm" />
</span>
)}
</button>
{allRequestsTypeId !== undefined && onOpenAllRequests && (
<button
type="button"
className={styles.allRequestsButton}
title="Все заявки этого типа"
onClick={() => onOpenAllRequests(allRequestsTypeId)}
>
<Icon name="people" size="sm" />
</button>
)}
</button>
</div>
);
})}
</div>
@@ -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<IRequestPortalProps> = ({ 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<IRequestPortalProps> = ({ hasTeamsContext, context
// Переход в «Все заявки» — либо с бокового меню (typeId не задан, раздел сам
// выберет тип), либо с кнопки формы (typeId уже известен, см.
// gotoAllRequestsForType и SubmitFeature.onOpenAllRequests).
// gotoAllRequestsForType и SubmitFeature.onOpenAllRequests), либо смена типа
// в самом разделе (пикер в AllRequestsFeature). Запоминаем выбор — как и
// persistOwnTab, только при явном выборе (в т.ч. том, что раздел сделал
// сам по умолчанию), чтобы следующий заход открыл тот же тип.
const gotoAllRequestsType = (typeId: number): void => {
persistAllTypeId(typeId);
navigate({
mode: 'submit',
nav: 'allRequests',
@@ -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<ISubmitFeatureProps> = ({
const requests = getRequestsByCategory(activeCategoryId);
const isTester = useIsTester();
// Кнопка «Все заявки этого типа» — только на форме открытого типа, и только
// если текущему пользователю в админке approval-route выдан доступ к нему
// (см. shared/approval/requestTypeAccess.ts). requestType.id формы совпадает
// с pageUrl approval-route по соглашению (см. useRequestTypeId).
const [allRequestsTypeId, setAllRequestsTypeId] = useState<number | undefined>(undefined);
const [canOpenAllRequests, setCanOpenAllRequests] = useState<boolean>(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<ISubmitFeatureProps> = ({
// которой они пришли (formKey совпал).
const external = initialForm && request.id === initialForm.formKey ? initialForm : undefined;
const allRequestsTypeId = accessibleAllRequestsIds?.get(request.id);
// Ленивый рендер формы: её код подтягивается только сейчас, при открытии.
return (
<>
{canOpenAllRequests && allRequestsTypeId && onOpenAllRequests && (
{allRequestsTypeId !== undefined && onOpenAllRequests && (
<div className={styles.formToolbar}>
<button
type="button"
@@ -185,7 +166,12 @@ const SubmitFeature: React.FC<ISubmitFeatureProps> = ({
{selectedRequest ? (
renderSelected(selectedRequest)
) : (
<RequestGrid requests={requests} onSelect={handleSelect} />
<RequestGrid
requests={requests}
onSelect={handleSelect}
accessibleAllRequestsIds={accessibleAllRequestsIds}
onOpenAllRequests={onOpenAllRequests}
/>
)}
</div>
);
@@ -35,6 +35,7 @@ export {
resolveRoute
} from './approvalRoute.service';
export { hasRequestTypeAccess, accessibleAllRequestsTypes } from './requestTypeAccess';
export { useAccessibleAllRequestsFormKeys } from './useAccessibleAllRequestsFormKeys';
export {
initListPartitions,
listPartitionsLoaded,
@@ -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<string, number> | undefined {
const [map, setMap] = useState<Map<string, number> | 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;
}
+42 -6
View File
@@ -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, минус, буквы) трактуем как первую страницу, а не как ошибку: