Feature/request type access all requests #22
@@ -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;
|
||||
}
|
||||
@@ -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, минус, буквы) трактуем как первую страницу, а не как ошибку:
|
||||
|
||||
Reference in New Issue
Block a user