Персистить выбранный тип «Все заявки» и добавить кнопку на плитках
Тип теперь запоминается в маршруте (rpalltype) и localStorage — тем же приёмом, что и подвкладка «Моих заявок», а не сбрасывается на первый доступный при каждом переходе в раздел. Кнопка «Все заявки этого типа» теперь есть не только на открытой форме, но и прямо на плитке типа заявки (до её открытия) — один общий запрос по всем типам разом (useAccessibleAllRequestsFormKeys), а не по одному на плитку. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,34 @@
|
|||||||
gap: 14px;
|
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 {
|
.tile {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -7,9 +7,18 @@ import { useIsTester } from '../../shared/useIsTester';
|
|||||||
interface IRequestGridProps {
|
interface IRequestGridProps {
|
||||||
requests: IRequestType[];
|
requests: IRequestType[];
|
||||||
onSelect: (request: IRequestType) => void;
|
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).
|
// их до запуска для всех (см. shared/useIsTester).
|
||||||
const isTester = useIsTester() === true;
|
const isTester = useIsTester() === true;
|
||||||
@@ -22,33 +31,45 @@ export const RequestGrid: React.FC<IRequestGridProps> = ({ requests, onSelect })
|
|||||||
<div className={styles.grid}>
|
<div className={styles.grid}>
|
||||||
{requests.map((request) => {
|
{requests.map((request) => {
|
||||||
const isLocked = !!request.comingSoon && !isTester;
|
const isLocked = !!request.comingSoon && !isTester;
|
||||||
|
const allRequestsTypeId = accessibleAllRequestsIds?.get(request.id);
|
||||||
return (
|
return (
|
||||||
<button
|
<div key={request.id} className={styles.tileWrap}>
|
||||||
key={request.id}
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={isLocked ? `${styles.tile} ${styles.tileDisabled}` : styles.tile}
|
className={isLocked ? `${styles.tile} ${styles.tileDisabled}` : styles.tile}
|
||||||
disabled={isLocked}
|
disabled={isLocked}
|
||||||
onClick={() => onSelect(request)}
|
onClick={() => onSelect(request)}
|
||||||
>
|
>
|
||||||
<span className={styles.tileIcon}>
|
<span className={styles.tileIcon}>
|
||||||
<Icon name={request.icon} />
|
<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>
|
</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>
|
</div>
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ import type { ISidebarItem } from '@tnsu/ui-kit';
|
|||||||
import { ConfirmHost } from '../shared/ui';
|
import { ConfirmHost } from '../shared/ui';
|
||||||
import {
|
import {
|
||||||
navigate,
|
navigate,
|
||||||
|
persistAllTypeId,
|
||||||
persistNav,
|
persistNav,
|
||||||
persistOwnTab,
|
persistOwnTab,
|
||||||
persistOwnStatus,
|
persistOwnStatus,
|
||||||
persistPage,
|
persistPage,
|
||||||
persistQuery,
|
persistQuery,
|
||||||
|
storedAllTypeId,
|
||||||
storedOwnStatus,
|
storedOwnStatus,
|
||||||
storedOwnTab,
|
storedOwnTab,
|
||||||
storedPageFor,
|
storedPageFor,
|
||||||
@@ -230,6 +232,19 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Тип заявки в «Все заявки» — из запомненного (см. gotoAllRequestsType
|
||||||
|
// ниже, откуда он и запоминается). Не задан ни разу — undefined, раздел
|
||||||
|
// сам выберет первый доступный тип (см. AllRequestsFeature).
|
||||||
|
if (nav === 'allRequests') {
|
||||||
|
navigate({
|
||||||
|
mode: 'submit',
|
||||||
|
nav,
|
||||||
|
allTypeId: storedAllTypeId(),
|
||||||
|
page: storedPageFor(nav),
|
||||||
|
query: storedQueryFor(nav) || undefined,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
navigate({
|
navigate({
|
||||||
mode: 'submit',
|
mode: 'submit',
|
||||||
nav,
|
nav,
|
||||||
@@ -255,8 +270,12 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
|
|||||||
|
|
||||||
// Переход в «Все заявки» — либо с бокового меню (typeId не задан, раздел сам
|
// Переход в «Все заявки» — либо с бокового меню (typeId не задан, раздел сам
|
||||||
// выберет тип), либо с кнопки формы (typeId уже известен, см.
|
// выберет тип), либо с кнопки формы (typeId уже известен, см.
|
||||||
// gotoAllRequestsForType и SubmitFeature.onOpenAllRequests).
|
// gotoAllRequestsForType и SubmitFeature.onOpenAllRequests), либо смена типа
|
||||||
|
// в самом разделе (пикер в AllRequestsFeature). Запоминаем выбор — как и
|
||||||
|
// persistOwnTab, только при явном выборе (в т.ч. том, что раздел сделал
|
||||||
|
// сам по умолчанию), чтобы следующий заход открыл тот же тип.
|
||||||
const gotoAllRequestsType = (typeId: number): void => {
|
const gotoAllRequestsType = (typeId: number): void => {
|
||||||
|
persistAllTypeId(typeId);
|
||||||
navigate({
|
navigate({
|
||||||
mode: 'submit',
|
mode: 'submit',
|
||||||
nav: 'allRequests',
|
nav: 'allRequests',
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { IRequestType } from '../../models/types';
|
|||||||
import { emitRequestsChanged } from '../../shared/requestEvents';
|
import { emitRequestsChanged } from '../../shared/requestEvents';
|
||||||
import { confirmDiscardFormChanges, markFormClean } from '../../shared/unsavedChanges';
|
import { confirmDiscardFormChanges, markFormClean } from '../../shared/unsavedChanges';
|
||||||
import { useIsTester } from '../../shared/useIsTester';
|
import { useIsTester } from '../../shared/useIsTester';
|
||||||
import { getRequestTypeIdByPageUrl, hasRequestTypeAccess } from '../../shared/approval';
|
import { useAccessibleAllRequestsFormKeys } from '../../shared/approval';
|
||||||
|
|
||||||
export interface ISubmitFeatureProps {
|
export interface ISubmitFeatureProps {
|
||||||
context: WebPartContext;
|
context: WebPartContext;
|
||||||
@@ -68,33 +68,12 @@ const SubmitFeature: React.FC<ISubmitFeatureProps> = ({
|
|||||||
const requests = getRequestsByCategory(activeCategoryId);
|
const requests = getRequestsByCategory(activeCategoryId);
|
||||||
const isTester = useIsTester();
|
const isTester = useIsTester();
|
||||||
|
|
||||||
// Кнопка «Все заявки этого типа» — только на форме открытого типа, и только
|
// Кнопка «Все заявки этого типа» — и на плитке, и на открытой форме, только
|
||||||
// если текущему пользователю в админке approval-route выдан доступ к нему
|
// если текущему пользователю в админке approval-route выдан доступ к этому
|
||||||
// (см. shared/approval/requestTypeAccess.ts). requestType.id формы совпадает
|
// типу (см. shared/approval/requestTypeAccess.ts). Один общий поход по всем
|
||||||
// с pageUrl approval-route по соглашению (см. useRequestTypeId).
|
// типам разом (formKey формы совпадает с pageUrl approval-route по
|
||||||
const [allRequestsTypeId, setAllRequestsTypeId] = useState<number | undefined>(undefined);
|
// соглашению, см. useRequestTypeId) — не по одному запросу на каждую плитку.
|
||||||
const [canOpenAllRequests, setCanOpenAllRequests] = useState<boolean>(false);
|
const accessibleAllRequestsIds = useAccessibleAllRequestsFormKeys(context);
|
||||||
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]);
|
|
||||||
|
|
||||||
// Возврат к плиткам закрывает форму и теряет введённое (раздел остаётся
|
// Возврат к плиткам закрывает форму и теряет введённое (раздел остаётся
|
||||||
// смонтированным, но состояние формы живёт в самом FormComponent). Спрашиваем,
|
// смонтированным, но состояние формы живёт в самом FormComponent). Спрашиваем,
|
||||||
@@ -138,10 +117,12 @@ const SubmitFeature: React.FC<ISubmitFeatureProps> = ({
|
|||||||
// которой они пришли (formKey совпал).
|
// которой они пришли (formKey совпал).
|
||||||
const external = initialForm && request.id === initialForm.formKey ? initialForm : undefined;
|
const external = initialForm && request.id === initialForm.formKey ? initialForm : undefined;
|
||||||
|
|
||||||
|
const allRequestsTypeId = accessibleAllRequestsIds?.get(request.id);
|
||||||
|
|
||||||
// Ленивый рендер формы: её код подтягивается только сейчас, при открытии.
|
// Ленивый рендер формы: её код подтягивается только сейчас, при открытии.
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{canOpenAllRequests && allRequestsTypeId && onOpenAllRequests && (
|
{allRequestsTypeId !== undefined && onOpenAllRequests && (
|
||||||
<div className={styles.formToolbar}>
|
<div className={styles.formToolbar}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -185,7 +166,12 @@ const SubmitFeature: React.FC<ISubmitFeatureProps> = ({
|
|||||||
{selectedRequest ? (
|
{selectedRequest ? (
|
||||||
renderSelected(selectedRequest)
|
renderSelected(selectedRequest)
|
||||||
) : (
|
) : (
|
||||||
<RequestGrid requests={requests} onSelect={handleSelect} />
|
<RequestGrid
|
||||||
|
requests={requests}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
accessibleAllRequestsIds={accessibleAllRequestsIds}
|
||||||
|
onOpenAllRequests={onOpenAllRequests}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export {
|
|||||||
resolveRoute
|
resolveRoute
|
||||||
} from './approvalRoute.service';
|
} from './approvalRoute.service';
|
||||||
export { hasRequestTypeAccess, accessibleAllRequestsTypes } from './requestTypeAccess';
|
export { hasRequestTypeAccess, accessibleAllRequestsTypes } from './requestTypeAccess';
|
||||||
|
export { useAccessibleAllRequestsFormKeys } from './useAccessibleAllRequestsFormKeys';
|
||||||
export {
|
export {
|
||||||
initListPartitions,
|
initListPartitions,
|
||||||
listPartitionsLoaded,
|
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 {
|
export function persistNav(nav: NavSection): void {
|
||||||
try {
|
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 ownTab = nav === 'own' ? parseOwnTab(params.get('rptab') ?? undefined) : undefined;
|
||||||
const ownStatus = ownTab === 'created' ? params.get('rpstatus') ?? readStoredOwnStatus() : undefined;
|
const ownStatus = ownTab === 'created' ? params.get('rpstatus') ?? readStoredOwnStatus() : undefined;
|
||||||
|
|
||||||
// Тип заявки в разделе «Все заявки» — только из адреса: в отличие от
|
// Тип заявки в разделе «Все заявки» — из адреса, иначе запомненный (та же
|
||||||
// ownTab/ownStatus, здесь не читаем «запомненное» значение при его отсутствии
|
// логика, что у ownTab/ownStatus выше). Если ни того, ни другого нет (первый
|
||||||
// (список доступных типов не известен без похода в API, поэтому выбор
|
// визит) — undefined, и раздел сам выберет первый доступный тип (см.
|
||||||
// значения по умолчанию отдаём самому разделу, см. AllRequestsFeature).
|
// AllRequestsFeature), а как только он выберет — маршрут и localStorage
|
||||||
const allTypeIdRaw = nav === 'allRequests' ? Number(params.get('rpalltype')) : NaN;
|
// подхватят выбор (см. gotoAllRequestsType в RequestPortal.tsx).
|
||||||
const allTypeId = Number.isInteger(allTypeIdRaw) && allTypeIdRaw > 0 ? allTypeIdRaw : undefined;
|
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, минус, буквы) трактуем как первую страницу, а не как ошибку:
|
// параметре (0, минус, буквы) трактуем как первую страницу, а не как ошибку:
|
||||||
|
|||||||
Reference in New Issue
Block a user