Персистить выбранный тип «Все заявки» и добавить кнопку на плитках

Тип теперь запоминается в маршруте (rpalltype) и localStorage — тем же
приёмом, что и подвкладка «Моих заявок», а не сбрасывается на первый
доступный при каждом переходе в раздел.

Кнопка «Все заявки этого типа» теперь есть не только на открытой форме,
но и прямо на плитке типа заявки (до её открытия) — один общий запрос
по всем типам разом (useAccessibleAllRequestsFormKeys), а не по одному
на плитку.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
gorbenko.pavel
2026-09-25 15:50:40 +05:00
parent fac11f3b6b
commit d079b3666a
7 changed files with 188 additions and 62 deletions
@@ -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;
}
+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 { 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, минус, буквы) трактуем как первую страницу, а не как ошибку: